From c19039d289ae8040380e00828910d7128e6604f5 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 09:05:06 +0200 Subject: [PATCH 01/22] feat(ffe): Add dd-openfeature SDK flag configuration and evaluation core Introduce the standalone :dd-openfeature module, not published yet, with the Universal Flag Configuration model and parser and the local flag evaluator. It depends on no dd-trace-java internal module. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 1 + dd-openfeature/build.gradle.kts | 38 ++ dd-openfeature/gradle.lockfile | 78 +++ .../evaluation/EvaluationContext.java | 10 + .../evaluation/EvaluationMetadata.java | 50 ++ .../internal/evaluation/EvaluationResult.java | 137 +++++ .../internal/evaluation/FlagEvaluator.java | 547 +++++++++++++++++ .../openfeature/internal/ufc/Allocation.java | 28 + .../internal/ufc/ConditionConfiguration.java | 18 + .../internal/ufc/ConditionOperator.java | 19 + .../openfeature/internal/ufc/Environment.java | 9 + .../openfeature/internal/ufc/Flag.java | 25 + .../internal/ufc/ParsedSemver.java | 318 ++++++++++ .../openfeature/internal/ufc/Rule.java | 11 + .../internal/ufc/ServerConfiguration.java | 32 + .../openfeature/internal/ufc/Shard.java | 16 + .../openfeature/internal/ufc/ShardRange.java | 12 + .../openfeature/internal/ufc/Split.java | 24 + .../ufc/UniversalFlagConfigParser.java | 568 ++++++++++++++++++ .../openfeature/internal/ufc/ValueType.java | 9 + .../openfeature/internal/ufc/Variant.java | 11 + .../internal/ufc/ParsedSemverTest.java | 220 +++++++ .../UniversalFlagConfigParserJsonApiTest.java | 435 ++++++++++++++ .../ufc/UniversalFlagConfigParserTest.java | 273 +++++++++ .../allocation-window-dates.json | 35 ++ .../resources/remote-config/empty-config.json | 8 + .../remote-config/malformed-allocations.json | 45 ++ .../resources/remote-config/null-flag.json | 34 ++ .../resources/remote-config/null-flags.json | 8 + .../resources/remote-config/serial-id.json | 34 ++ .../remote-config/sibling-serial-ids.json | 59 ++ .../remote-config/unknown-operator.json | 67 +++ .../unknown-top-level-fields.json | 13 + gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + 35 files changed, 3194 insertions(+) create mode 100644 dd-openfeature/build.gradle.kts create mode 100644 dd-openfeature/gradle.lockfile create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java create mode 100644 dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json create mode 100644 dd-openfeature/src/test/resources/remote-config/empty-config.json create mode 100644 dd-openfeature/src/test/resources/remote-config/malformed-allocations.json create mode 100644 dd-openfeature/src/test/resources/remote-config/null-flag.json create mode 100644 dd-openfeature/src/test/resources/remote-config/null-flags.json create mode 100644 dd-openfeature/src/test/resources/remote-config/serial-id.json create mode 100644 dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json create mode 100644 dd-openfeature/src/test/resources/remote-config/unknown-operator.json create mode 100644 dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 671d37e3d72..660d14776d4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -355,6 +355,7 @@ **/kafka*/**/*Iast* @DataDog/asm-java @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 diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts new file mode 100644 index 00000000000..17e9f6ee412 --- /dev/null +++ b/dd-openfeature/build.gradle.kts @@ -0,0 +1,38 @@ +import datadog.gradle.configureCompiler +import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension + +plugins { + `java-library` + idea + id("dd-trace-java.conventions.java") +} + +description = "Datadog OpenFeature provider SDK." + +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(libs.jackson.core) + + testImplementation(libs.bundles.junit5) + testImplementation(libs.bundles.mockito) +} + +tasks.withType().configureEach { + configureCompiler(JavaVersion.VERSION_11) +} diff --git a/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile new file mode 100644 index 00000000000..c878fe4af10 --- /dev/null +++ b/dd-openfeature/gradle.lockfile @@ -0,0 +1,78 @@ +# 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 +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/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/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/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java new file mode 100644 index 00000000000..c5d79cd67e1 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java @@ -0,0 +1,18 @@ +package com.datadog.openfeature.internal.ufc; + +public final class ConditionConfiguration { + public final ConditionOperator operator; + public final String attribute; + public final Object value; + + // The validated, parsed SemVer condition value. Set during configuration preprocessing + // (not from JSON) when the operator is a SEMVER_* operator. + public transient ParsedSemver semverComparand; + + public ConditionConfiguration( + final ConditionOperator operator, final String attribute, final Object value) { + this.operator = operator; + this.attribute = attribute; + this.value = value; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java new file mode 100644 index 00000000000..fc3d3bab683 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java @@ -0,0 +1,19 @@ +package com.datadog.openfeature.internal.ufc; + +public enum ConditionOperator { + LT, + LTE, + GT, + GTE, + MATCHES, + NOT_MATCHES, + ONE_OF, + NOT_ONE_OF, + IS_NULL, + SEMVER_EQ, + SEMVER_NEQ, + SEMVER_LT, + SEMVER_LTE, + SEMVER_GT, + SEMVER_GTE +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java new file mode 100644 index 00000000000..664ae3fa07e --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.ufc; + +public final class Environment { + public final String name; + + public Environment(String name) { + this.name = name; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java new file mode 100644 index 00000000000..8ff4dd83989 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java @@ -0,0 +1,25 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; +import java.util.Map; + +public final class Flag { + public final String key; + public final boolean enabled; + public final ValueType variationType; + public final Map variations; + public final List allocations; + + public Flag( + final String key, + final boolean enabled, + final ValueType variationType, + final Map variations, + final List allocations) { + this.key = key; + this.enabled = enabled; + this.variationType = variationType; + this.variations = variations; + this.allocations = allocations; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java new file mode 100644 index 00000000000..c4f99828516 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java @@ -0,0 +1,318 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.Arrays; + +/** + * ParsedSemver is the language-neutral representation of the SemVer subset used by FFE. Owning this + * parser and comparator keeps behavior consistent across SDKs and lets configuration preprocessing + * cache comparands instead of reparsing them during every evaluation. + * + *

Core identifiers (major, minor, patch) are limited to unsigned 64-bit integers, stored in + * {@code long} fields and compared with unsigned semantics. Numeric prerelease identifiers may be + * arbitrarily large and are compared by length-then-lexicographic order. Build metadata is + * validated during parsing but not retained because it does not affect SemVer precedence. + */ +public final class ParsedSemver { + + /** Sentinel returned by {@link #parse(String)} when the input is not a valid semantic version. */ + public static final ParsedSemver INVALID = null; + + private final long[] core; // unsigned, with omitted minor and patch normalized to zero + private final int coreLength; + private final String prerelease; + + ParsedSemver(final long[] core, final int coreLength, final String prerelease) { + this.core = core; + this.coreLength = coreLength; + this.prerelease = prerelease; + } + + /** + * Parses a semantic version string using the syntax supported by FFE. + * + * @param version the version string to parse + * @return a {@link ParsedSemver} instance, or {@code null} if the input is not a valid semantic + * version + */ + public static ParsedSemver parse(final String version) { + long[] core = new long[3]; + int coreSize = 0; + int next = 0; + while (true) { + final int end = coreIdentifierEnd(version, next); + if (end == -1) { + return INVALID; + } + final long component; + try { + component = Long.parseUnsignedLong(version.substring(next, end)); + } catch (final NumberFormatException e) { + return INVALID; // overflow + } + if (coreSize == core.length) { + core = Arrays.copyOf(core, core.length * 2); + } + core[coreSize++] = component; + next = end; + if (next == version.length() || version.charAt(next) != '.') { + break; + } + next++; + } + final int coreLength = Math.max(3, coreSize); + if (next == version.length()) { + return new ParsedSemver(core, coreLength, ""); + } + + // Parse prerelease and/or build metadata. + String remainder = version.substring(next); + String prerelease = ""; + if (remainder.charAt(0) == '-') { + remainder = remainder.substring(1); + final int buildStart = remainder.indexOf('+'); + if (buildStart == -1) { + if (!validSemverIdentifiers(remainder, false)) { + return INVALID; + } + return new ParsedSemver(core, coreLength, remainder); + } + prerelease = remainder.substring(0, buildStart); + if (!validSemverIdentifiers(prerelease, false)) { + return INVALID; + } + remainder = remainder.substring(buildStart + 1); + } else if (remainder.charAt(0) == '+') { + remainder = remainder.substring(1); + } else { + return INVALID; + } + if (!validSemverIdentifiers(remainder, true)) { + return INVALID; + } + return new ParsedSemver(core, coreLength, prerelease); + } + + /** + * Compares two parsed semantic versions by precedence. Build metadata is intentionally ignored. + * + * @param left the left operand + * @param right the right operand + * @return negative if {@code left} precedes {@code right}, positive if {@code left} follows + * {@code right}, zero if they have equal precedence + */ + public static int compare(final ParsedSemver left, final ParsedSemver right) { + final int coreLength = Math.max(left.coreLength, right.coreLength); + for (int i = 0; i < coreLength; i++) { + final int cmp = Long.compareUnsigned(left.coreComponent(i), right.coreComponent(i)); + if (cmp != 0) { + return cmp; + } + } + return comparePrerelease(left.prerelease, right.prerelease); + } + + /** + * Finds the end of a core version identifier (major, minor, or patch), rejecting leading zeros + * except for the value zero itself. + */ + private static int coreIdentifierEnd(final String version, final int start) { + if (start >= version.length() || !isASCIIDigit(version.charAt(start))) { + return -1; + } + if (version.charAt(start) == '0') { + return start + 1; + } + + int end = start; + while (end < version.length() && isASCIIDigit(version.charAt(end))) { + end++; + } + return end; + } + + private long coreComponent(final int index) { + return index < coreLength ? core[index] : 0; + } + + /** + * Returns the number of significant core components, excluding trailing zero components that + * {@link #compare} treats as equal to omitted components. This keeps {@link #equals} and {@link + * #hashCode} consistent with precedence comparison so that versions like {@code 1.2.3} and {@code + * 1.2.3.0} are equal and hash identically. + */ + private int effectiveCoreLength() { + int length = coreLength; + while (length > 0 && core[length - 1] == 0L) { + length--; + } + return length; + } + + /** + * Validates dot-separated identifiers. Permits leading zeros for build metadata only; numeric + * prerelease identifiers reject them. + */ + private static boolean validSemverIdentifiers( + final String value, final boolean allowLeadingZeros) { + int identifierStart = 0; + boolean identifierNumeric = true; + for (int i = 0; i <= value.length(); i++) { + if (i == value.length() || value.charAt(i) == '.') { + if (i == identifierStart) { + return false; // empty identifier + } + if (!allowLeadingZeros + && identifierNumeric + && i - identifierStart > 1 + && value.charAt(identifierStart) == '0') { + return false; // leading zero in numeric identifier + } + identifierStart = i + 1; + identifierNumeric = true; + continue; + } + + final char c = value.charAt(i); + if (!isASCIIAlphanumeric(c) && c != '-') { + return false; + } + if (!isASCIIDigit(c)) { + identifierNumeric = false; + } + } + return true; + } + + private static int comparePrerelease(final String left, final String right) { + if (left.equals(right)) { + return 0; + } + if (left.isEmpty()) { + return 1; // release > prerelease + } + if (right.isEmpty()) { + return -1; // prerelease < release + } + + int leftPos = 0; + int rightPos = 0; + while (true) { + // Extract next identifier from each side + int leftDot = left.indexOf('.', leftPos); + int rightDot = right.indexOf('.', rightPos); + final String leftIdentifier = + leftDot == -1 ? left.substring(leftPos) : left.substring(leftPos, leftDot); + final String rightIdentifier = + rightDot == -1 ? right.substring(rightPos) : right.substring(rightPos, rightDot); + + final int ordering = compareIdentifier(leftIdentifier, rightIdentifier); + if (ordering != 0) { + return ordering; + } + + if (leftDot == -1) { + if (rightDot == -1) { + return 0; + } + return -1; // left has fewer identifiers + } + if (rightDot == -1) { + return 1; // right has fewer identifiers + } + leftPos = leftDot + 1; + rightPos = rightDot + 1; + } + } + + private static int compareIdentifier(final String left, final String right) { + final boolean leftNumeric = isNumericIdentifier(left); + final boolean rightNumeric = isNumericIdentifier(right); + + if (leftNumeric && rightNumeric) { + // Numeric identifiers: compare by length first (longer = larger), then lexicographically + if (left.length() < right.length()) { + return -1; + } + if (left.length() > right.length()) { + return 1; + } + return left.compareTo(right); + } else if (leftNumeric) { + return -1; // numeric < alphanumeric + } else if (rightNumeric) { + return 1; // alphanumeric > numeric + } + return left.compareTo(right); + } + + private static boolean isNumericIdentifier(final String value) { + for (int i = 0; i < value.length(); i++) { + if (!isASCIIDigit(value.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isASCIIDigit(final char c) { + return c >= '0' && c <= '9'; + } + + private static boolean isASCIIAlphanumeric(final char c) { + return isASCIIDigit(c) || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + } + + // --- Accessors for testing --- + + long getMajor() { + return core[0]; + } + + long getMinor() { + return core[1]; + } + + long getPatch() { + return core[2]; + } + + String getPrerelease() { + return prerelease; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ParsedSemver)) { + return false; + } + // Delegate to compare so equality matches precedence (SEMVER_EQ) and sorted collections, even + // when versions differ only by trailing zero components such as 1.2.3 and 1.2.3.0. + return compare(this, (ParsedSemver) o) == 0; + } + + @Override + public int hashCode() { + // Hash over the trailing-zero-stripped core so that compare-equal versions hash identically. + final int effectiveLength = effectiveCoreLength(); + int result = 1; + for (int i = 0; i < effectiveLength; i++) { + result = 31 * result + Long.hashCode(core[i]); + } + return 31 * result + prerelease.hashCode(); + } + + @Override + public String toString() { + final StringBuilder value = new StringBuilder(); + for (int i = 0; i < coreLength; i++) { + if (i > 0) { + value.append('.'); + } + value.append(Long.toUnsignedString(coreComponent(i))); + } + return value.append(prerelease.isEmpty() ? "" : "-" + prerelease).toString(); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java new file mode 100644 index 00000000000..819ebeeaf23 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java @@ -0,0 +1,11 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; + +public final class Rule { + public final List conditions; + + public Rule(final List conditions) { + this.conditions = conditions; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java new file mode 100644 index 00000000000..96aa6288154 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java @@ -0,0 +1,32 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.Collections; +import java.util.Map; + +public final class ServerConfiguration { + public final String createdAt; + public final String format; + + /** 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; + + // Flags that could not be parsed or validated. The key is the flag key; the value is the error + // type (e.g. "invalid_semver_comparand"). Set during configuration preprocessing, not from JSON. + public transient Map invalidFlags = Collections.emptyMap(); + + public ServerConfiguration( + final String createdAt, + final String format, + final Boolean observeFullEvaluationData, + final Environment environment, + final Map flags) { + this.createdAt = createdAt; + this.format = format; + this.observeFullEvaluationData = observeFullEvaluationData; + this.environment = environment; + this.flags = flags; + } +} diff --git a/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/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java new file mode 100644 index 00000000000..c4852517adc --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java @@ -0,0 +1,24 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; +import java.util.Map; + +public final class Split { + public final List shards; + public final String variationKey; + public final Map extraLogging; + + /** Absent in some UFC shapes. Surfaced as {@code __dd_split_serial_id} for span enrichment. */ + public final Integer serialId; + + public Split( + final List shards, + final String variationKey, + final Map extraLogging, + final Integer serialId) { + this.shards = shards; + this.variationKey = variationKey; + this.extraLogging = extraLogging; + this.serialId = serialId; + } +} 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/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java new file mode 100644 index 00000000000..4b260059ae9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.ufc; + +public enum ValueType { + BOOLEAN, + INTEGER, + NUMERIC, + STRING, + JSON +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java new file mode 100644 index 00000000000..4d9246c5965 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java @@ -0,0 +1,11 @@ +package com.datadog.openfeature.internal.ufc; + +public final class Variant { + public final String key; + public final Object value; + + public Variant(final String key, final Object value) { + this.key = key; + this.value = value; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java new file mode 100644 index 00000000000..435bf9ac17f --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java @@ -0,0 +1,220 @@ +package com.datadog.openfeature.internal.ufc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class ParsedSemverTest { + + // --- Parse tests (ported from Go semver_test.go TestParseSemver) --- + + static Stream validVersions() { + return Stream.of( + Arguments.of("0.0.0", 0L, 0L, 0L, ""), + Arguments.of( + "18446744073709551615.18446744073709551615.18446744073709551615", + Long.parseUnsignedLong("18446744073709551615"), + Long.parseUnsignedLong("18446744073709551615"), + Long.parseUnsignedLong("18446744073709551615"), + ""), + Arguments.of("1.2.3-alpha.1", 1L, 2L, 3L, "alpha.1"), + Arguments.of("1.2.3-18446744073709551616", 1L, 2L, 3L, "18446744073709551616"), + Arguments.of("1.2.3+build.001", 1L, 2L, 3L, ""), + Arguments.of("1.2.3-alpha-1+build.001", 1L, 2L, 3L, "alpha-1")); + } + + @ParameterizedTest(name = "valid: {0}") + @MethodSource("validVersions") + void testParseValid( + final String version, + final long major, + final long minor, + final long patch, + final String prerelease) { + final ParsedSemver parsed = ParsedSemver.parse(version); + assertTrue(parsed != null, "expected " + version + " to parse"); + assertEquals(major, parsed.getMajor()); + assertEquals(minor, parsed.getMinor()); + assertEquals(patch, parsed.getPatch()); + assertEquals(prerelease, parsed.getPrerelease()); + } + + static Stream invalidVersions() { + return Stream.of( + "", + "x", + "v1.2.3", + "01.2.3", + "1.02.3", + "1.2.03", + "18446744073709551616.0.0", + "0.18446744073709551616.0", + "0.0.18446744073709551616", + "1.2.3-", + "1.2.3+", + "1.2.3-alpha..1", + "1.2.3+build..1", + "1.2.3-01", + "1.2.3-alpha_1", + "1.2.3-alpha+build+other", + "1.2.3-α", + " 1.2.3", + "1.2.3 "); + } + + @ParameterizedTest(name = "invalid: {0}") + @MethodSource("invalidVersions") + void testParseInvalid(final String version) { + assertNull(ParsedSemver.parse(version), "expected " + version + " to be invalid"); + } + + // --- Compare tests (ported from Go semver_test.go TestCompareSemver) --- + + /** The canonical SemVer precedence ordering from the spec. */ + private static final String[] ORDERED_VERSIONS = { + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "1.1.0", + "2.0.0", + }; + + @Test + void normalizesMissingCorePartsAndComparesExtendedCoreParts() { + assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18"), ParsedSemver.parse("18.0.0"))); + assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18.0"), ParsedSemver.parse("18.0.0"))); + assertEquals( + 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0"))); + assertEquals( + 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0.0"))); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("1.2.3.4"), ParsedSemver.parse("1.2.3.5")) < 0); + assertTrue(ParsedSemver.parse("1.2.3.4.5.6") != null); + assertTrue(ParsedSemver.parse("1.2.3.4.5.6.7") != null); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5"), ParsedSemver.parse("1.2.3.4.6")) < 0); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5.6"), ParsedSemver.parse("1.2.3.4.5.7")) + < 0); + assertTrue( + ParsedSemver.compare( + ParsedSemver.parse("1.2.3.4.5.6.7"), ParsedSemver.parse("1.2.3.4.5.6.8")) + < 0); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("18.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("18.0.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); + } + + @Test + void testCompareSemverOrdering() { + for (int i = 0; i < ORDERED_VERSIONS.length; i++) { + final ParsedSemver left = ParsedSemver.parse(ORDERED_VERSIONS[i]); + assertTrue(left != null, "left parse failed: " + ORDERED_VERSIONS[i]); + for (int j = 0; j < ORDERED_VERSIONS.length; j++) { + final ParsedSemver right = ParsedSemver.parse(ORDERED_VERSIONS[j]); + assertTrue(right != null, "right parse failed: " + ORDERED_VERSIONS[j]); + final int ordering = ParsedSemver.compare(left, right); + if (i < j) { + assertTrue(ordering < 0, ORDERED_VERSIONS[i] + " should precede " + ORDERED_VERSIONS[j]); + } else if (i > j) { + assertTrue(ordering > 0, ORDERED_VERSIONS[i] + " should follow " + ORDERED_VERSIONS[j]); + } else { + assertEquals(0, ordering); + } + } + } + } + + @Test + void testCompareSemverArbitrarilyLargeNumericPrerelease() { + final ParsedSemver left = ParsedSemver.parse("1.0.0-99999999999999999999"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0-100000000000000000000"); + assertTrue(right != null); + assertTrue(ParsedSemver.compare(left, right) < 0); + } + + @Test + void testValueObjectMethods() { + final ParsedSemver value = ParsedSemver.parse("1.2.3-alpha"); + final ParsedSemver equalValue = ParsedSemver.parse("1.2.3-alpha"); + final ParsedSemver release = ParsedSemver.parse("1.2.3"); + assertTrue(value != null); + assertTrue(equalValue != null); + assertTrue(release != null); + assertTrue(value.equals(value)); + assertTrue(value.equals(equalValue)); + assertFalse(value.equals(null)); + assertFalse(value.equals("1.2.3-alpha")); + assertEquals(value.hashCode(), equalValue.hashCode()); + assertEquals("1.2.3-alpha", value.toString()); + assertEquals("1.2.3", release.toString()); + } + + @Test + void testCompareSemverNumericPrereleaseIdentifiersLexicographically() { + final ParsedSemver left = ParsedSemver.parse("1.0.0-10"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0-11"); + assertTrue(right != null); + assertTrue(ParsedSemver.compare(left, right) < 0); + } + + @Test + void testEqualsAndHashCodeNormalizeTrailingZeroComponents() { + // Versions that compare as equal must also be equals() and share a hashCode, even when they + // differ only by trailing zero components (1.2.3 vs 1.2.3.0 vs 1.2.3.0.0). + final ParsedSemver base = ParsedSemver.parse("1.2.3"); + final ParsedSemver trailingZero = ParsedSemver.parse("1.2.3.0"); + final ParsedSemver trailingZeros = ParsedSemver.parse("1.2.3.0.0"); + assertTrue(base != null); + assertTrue(trailingZero != null); + assertTrue(trailingZeros != null); + assertEquals(0, ParsedSemver.compare(base, trailingZero)); + assertEquals(0, ParsedSemver.compare(base, trailingZeros)); + assertEquals(base, trailingZero); + assertEquals(base, trailingZeros); + assertEquals(trailingZero, trailingZeros); + assertEquals(base.hashCode(), trailingZero.hashCode()); + assertEquals(base.hashCode(), trailingZeros.hashCode()); + assertEquals(trailingZero.hashCode(), trailingZeros.hashCode()); + + // All-zero versions of varying length are also compare-equal and must hash identically. + final ParsedSemver zero = ParsedSemver.parse("0"); + final ParsedSemver zeroZero = ParsedSemver.parse("0.0.0.0"); + assertTrue(zero != null); + assertTrue(zeroZero != null); + assertEquals(0, ParsedSemver.compare(zero, zeroZero)); + assertEquals(zero, zeroZero); + assertEquals(zero.hashCode(), zeroZero.hashCode()); + + // Distinct versions must remain unequal and may differ in hash. + final ParsedSemver release = ParsedSemver.parse("1.2.4"); + assertTrue(release != null); + assertNotEquals(base, release); + } + + @Test + void testCompareSemverBuildMetadataIsIgnored() { + final ParsedSemver left = ParsedSemver.parse("1.0.0+build.1"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0+build.2"); + assertTrue(right != null); + assertEquals(0, ParsedSemver.compare(left, right)); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java new file mode 100644 index 00000000000..8a7af915e14 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java @@ -0,0 +1,435 @@ +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class UniversalFlagConfigParserJsonApiTest { + + @Test + void parsesJsonApiMembersInAnyOrder() throws Exception { + final ServerConfiguration configuration = + parse( + "{" + + "\"meta\":{\"ignored\":true}," + + "\"data\":{" + + "\"attributes\":" + + emptyConfig() + + ",\"ignored\":true," + + "\"type\":\"universal-flag-configuration\"" + + "}" + + "}"); + + assertNotNull(configuration); + assertEquals("Test", configuration.environment.name); + assertTrue(configuration.flags.isEmpty()); + } + + @Test + void rejectsRawUfc() throws Exception { + assertNull(parse(emptyConfig())); + } + + @Test + void rejectsUnexpectedJsonApiType() throws Exception { + assertNull(parse("{\"data\":{\"type\":\"other-type\",\"attributes\":" + emptyConfig() + "}}")); + } + + @Test + void rejectsNonStringJsonApiType() throws Exception { + assertNull(parse("{\"data\":{\"type\":null,\"attributes\":" + emptyConfig() + "}}")); + } + + @Test + void rejectsConfigurationWithoutFlags() throws Exception { + assertNull( + parse( + "{\"data\":{" + + "\"type\":\"universal-flag-configuration\"," + + "\"attributes\":{\"environment\":{\"name\":\"Test\"}}" + + "}}")); + } + + @Test + void rejectsNonObjectData() throws Exception { + assertNull(parse("{\"data\":[]}")); + } + + @Test + void rejectsTrailingJson() { + assertThrows( + IOException.class, + () -> + parse( + "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + emptyConfig() + + "}}{}")); + } + + @Test + void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag("no-allocations", ""), + booleanFlag( + "no-splits", ",\"allocations\":[{\"key\":\"no-splits\",\"rules\":[]}]"), + booleanFlag( + "null-split", + ",\"allocations\":[{\"key\":\"null-split\",\"rules\":[],\"splits\":[null]}]"), + booleanFlag("no-rules", allocation("no-rules", "")), + booleanFlag("no-conditions", allocation("no-conditions", "[{}]")), + booleanFlag( + "no-operator", allocation("no-operator", "[{\"conditions\":[{}]}]")), + booleanFlag( + "non-semver", + allocation( + "non-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"1\"}]}]")), + booleanFlag( + "valid-semver", + allocation( + "valid-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.2\"}]}]")), + booleanFlag( + "invalid-semver", + allocation( + "invalid-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.02\"}]}]")), + booleanFlag( + "non-string-semver", + allocation( + "non-string-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":1}]}]")), + "\"null-flag\":null"))); + + assertNotNull(configuration); + assertTrue(configuration.flags.containsKey("no-allocations")); + assertTrue(configuration.flags.containsKey("no-splits")); + assertTrue(configuration.flags.containsKey("null-split")); + assertTrue(configuration.flags.containsKey("no-rules")); + assertTrue(configuration.flags.containsKey("no-conditions")); + assertTrue(configuration.flags.containsKey("no-operator")); + assertTrue(configuration.flags.containsKey("non-semver")); + assertTrue(configuration.flags.containsKey("valid-semver")); + assertFalse(configuration.flags.containsKey("invalid-semver")); + assertFalse(configuration.flags.containsKey("non-string-semver")); + assertFalse(configuration.flags.containsKey("null-flag")); + assertEquals(2, configuration.invalidFlags.size()); + assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); + assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); + + assertNotNull( + configuration + .flags + .get("valid-semver") + .allocations + .get(0) + .rules + .get(0) + .conditions + .get(0) + .semverComparand); + } + + @Test + void dropsFlagWithMalformedAllocationsWithoutRejectingConfig() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag("malformed-allocations", ",\"allocations\":\"not-a-list\""), + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("malformed-allocations")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("malformed-allocations")); + } + + @Test + void dropsFlagWithMissingSplitShards() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag( + "missing-shards", + ",\"allocations\":[{\"key\":\"missing-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\"}]}]"), + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("missing-shards")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("missing-shards")); + } + + @Test + void dropsFlagsWithInvalidConditionOperandsAndShardBounds() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag( + "non-numeric-gt", + allocation( + "non-numeric-gt", + "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"GT\",\"value\":\"bad\"}]}]")), + booleanFlag( + "non-list-one-of", + allocation( + "non-list-one-of", + "[{\"conditions\":[{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":\"admin\"}]}]")), + booleanFlag( + "negative-shard-range", + ",\"allocations\":[{\"key\":\"negative-shard-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":100,\"ranges\":[{\"start\":-1,\"end\":1}]}]}]}]"), + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("non-numeric-gt")); + assertFalse(configuration.flags.containsKey("non-list-one-of")); + assertFalse(configuration.flags.containsKey("negative-shard-range")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("non-numeric-gt")); + assertEquals("invalid_flag", configuration.invalidFlags.get("non-list-one-of")); + assertEquals("invalid_flag", configuration.invalidFlags.get("negative-shard-range")); + } + + @Test + void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag( + "zero-total-shards", + ",\"allocations\":[{\"key\":\"zero-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":0,\"ranges\":[]}]}]}]"), + booleanFlag( + "unsigned-total-shards", + ",\"allocations\":[{\"key\":\"unsigned-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":2147483648,\"ranges\":[{\"start\":2147483648,\"end\":2147483649}]}]}]}]"), + booleanFlag( + "too-many-shards", + ",\"allocations\":[{\"key\":\"too-many-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":4294967296,\"ranges\":[]}]}]}]"), + booleanFlag( + "missing-ranges", + ",\"allocations\":[{\"key\":\"missing-ranges\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1}]}]}]"), + booleanFlag( + "null-shard", + ",\"allocations\":[{\"key\":\"null-shard\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[null]}]}]"), + booleanFlag( + "null-range", + ",\"allocations\":[{\"key\":\"null-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[null]}]}]}]"), + booleanFlag( + "negative-range-end", + ",\"allocations\":[{\"key\":\"negative-range-end\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[{\"start\":0,\"end\":-1}]}]}]}]"), + booleanFlag( + "non-boolean-is-null", + allocation( + "non-boolean-is-null", + "[{\"conditions\":[{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":\"false\"}]}]")), + booleanFlag( + "valid-condition-operands", + allocation( + "valid-condition-operands", + "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"LT\",\"value\":1},{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":[\"admin\"]},{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":true}]}]"))))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("zero-total-shards")); + assertTrue(configuration.flags.containsKey("unsigned-total-shards")); + assertEquals( + 2_147_483_648L, + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .totalShards); + assertEquals( + 2_147_483_648L, + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .ranges + .get(0) + .start); + assertEquals( + 2_147_483_649L, + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .ranges + .get(0) + .end); + assertFalse(configuration.flags.containsKey("too-many-shards")); + assertFalse(configuration.flags.containsKey("missing-ranges")); + assertFalse(configuration.flags.containsKey("null-shard")); + assertFalse(configuration.flags.containsKey("null-range")); + assertFalse(configuration.flags.containsKey("negative-range-end")); + assertFalse(configuration.flags.containsKey("non-boolean-is-null")); + assertTrue(configuration.flags.containsKey("valid-condition-operands")); + } + + @Test + void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { + assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}")); + } + + @Test + void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { + // 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())); + assertNotNull(configuration); + assertFalse(Boolean.TRUE.equals(configuration.observeFullEvaluationData)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void observeFullEvaluationDataParsesExplicitValue(final boolean value) throws Exception { + final ServerConfiguration configuration = + parse(wrap(configWithObserveFullEvaluationData(value))); + assertNotNull(configuration); + assertEquals(value, configuration.observeFullEvaluationData); + } + + @Test + void observeFullEvaluationDataExplicitNullDefaultsToFalseWithoutRejectingConfig() + throws Exception { + // An explicit null (or a wrong-typed value) for this field must not abort the whole UFC parse. + // A pod that starts after a malformed UFC has no last-known-good, so aborting would strand + // every flag on PROVIDER_NOT_READY (its default value). We fail closed on privacy (consent + // stays false) but preserve availability: flags parse and evaluate. + final ServerConfiguration configuration = + parse(wrap(configWithRawObserveFullEvaluationData("null"))); + + assertNotNull(configuration); + assertFalse( + configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + @Test + void observeFullEvaluationDataWrongTypedStringDefaultsToFalse() throws Exception { + // 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\""))); + + assertNotNull(configuration); + assertFalse( + configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + @Test + void observeFullEvaluationDataWrongTypedNumberDefaultsToFalse() throws Exception { + final ServerConfiguration configuration = + parse(wrap(configWithRawObserveFullEvaluationData("1"))); + + assertNotNull(configuration); + assertFalse( + configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + private static ServerConfiguration parse(final String json) throws Exception { + return UniversalFlagConfigParser.parseJsonApi(json.getBytes(UTF_8)); + } + + private static String wrap(final String attributes) { + return "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + attributes + + "}}"; + } + + private static String configWithObserveFullEvaluationData(final boolean value) { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"observeFullEvaluationData\":" + + value + + "," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } + + private static String configWithRawObserveFullEvaluationData(final String rawJsonValue) { + // Emit the field with a caller-controlled raw JSON value (null / "true" / 1 / ...) so we can + // assert the parser's tolerance of malformed shapes without going through configWith's + // boolean-typed helper. + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"observeFullEvaluationData\":" + + rawJsonValue + + "," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } + + private static String configWithFlags(final String... flags) { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{" + + String.join(",", flags) + + "}}"; + } + + private static String booleanFlag(final String key, final String allocations) { + return "\"" + + key + + "\":{" + + "\"key\":\"" + + key + + "\",\"enabled\":true,\"variationType\":\"BOOLEAN\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":true}}" + + allocations + + "}"; + } + + private static String allocation(final String key, final String rules) { + return ",\"allocations\":[{\"key\":\"" + + key + + "\"" + + (rules.isEmpty() ? "" : ",\"rules\":" + rules) + + ",\"splits\":[]}]"; + } + + private static String emptyConfig() { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } +} diff --git a/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/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/dd-openfeature/src/test/resources/remote-config/serial-id.json b/dd-openfeature/src/test/resources/remote-config/serial-id.json new file mode 100644 index 00000000000..6b7dc926854 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/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/remote-config/sibling-serial-ids.json b/dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json new file mode 100644 index 00000000000..aa791903f66 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/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/remote-config/unknown-operator.json b/dd-openfeature/src/test/resources/remote-config/unknown-operator.json new file mode 100644 index 00000000000..a380463b1d5 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/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/remote-config/unknown-top-level-fields.json b/dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json new file mode 100644 index 00000000000..fe23d4a46b1 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index f106f80c147..5ca41e88055 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/settings.gradle.kts b/settings.gradle.kts index 67fbb3ad7a0..86a6b18b058 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", From 3f78321ef77ec8139944223477c891eba9f1cd3c Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:01:08 +0200 Subject: [PATCH 02/22] refactor(ffe): Drop EvaluationMetadata in favor of OpenFeature metadata The evaluator built its own metadata type that the provider copied into an OpenFeature ImmutableMetadata on every evaluation. The SDK already depends on the OpenFeature SDK, so build ImmutableMetadata directly. Co-Authored-By: Claude Opus 5.5 --- .../evaluation/EvaluationMetadata.java | 50 ------------------- .../internal/evaluation/EvaluationResult.java | 10 ++-- .../internal/evaluation/FlagEvaluator.java | 9 ++-- 3 files changed, 11 insertions(+), 58 deletions(-) delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java 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 deleted file mode 100644 index b1b17d91501..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java +++ /dev/null @@ -1,50 +0,0 @@ -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 index e83edfd9355..d542171ecb2 100644 --- 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 @@ -1,5 +1,7 @@ package com.datadog.openfeature.internal.evaluation; +import dev.openfeature.sdk.ImmutableMetadata; + /** A resolved value plus product metadata. Transport and SDK adapters consume this result. */ public final class EvaluationResult { public enum ErrorCode { @@ -26,7 +28,7 @@ public enum Reason { private final String variant; private final ErrorCode errorCode; private final String errorMessage; - private final EvaluationMetadata metadata; + private final ImmutableMetadata metadata; private final boolean logExposure; private final Integer serialId; @@ -61,7 +63,7 @@ public String getErrorMessage() { return errorMessage; } - public EvaluationMetadata getFlagMetadata() { + public ImmutableMetadata getFlagMetadata() { return metadata; } @@ -86,7 +88,7 @@ public static final class Builder { private String variant; private ErrorCode errorCode; private String errorMessage; - private EvaluationMetadata metadata; + private ImmutableMetadata metadata; private boolean logExposure; private Integer serialId; @@ -115,7 +117,7 @@ public Builder errorMessage(String errorMessage) { return this; } - public Builder flagMetadata(EvaluationMetadata metadata) { + public Builder flagMetadata(ImmutableMetadata metadata) { this.metadata = metadata; return 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 index ac0c16675b5..ff5b9bd4558 100644 --- 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 @@ -16,6 +16,7 @@ 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.ImmutableMetadata; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -168,8 +169,8 @@ public EvaluationResult evaluate( } } - private static EvaluationMetadata consentMetadata(final boolean observeFullEvaluationData) { - return EvaluationMetadata.builder() + private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { + return ImmutableMetadata.builder() .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) .build(); } @@ -434,8 +435,8 @@ private EvaluationResult resolveVariant( // 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() + final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = + ImmutableMetadata.builder() .addString("flagKey", flag.key) .addString("variationType", flag.variationType.name()) .addString("allocationKey", allocation.key) From ce7116636932c185b13c922026a607646dcdb2dc Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:24:31 +0200 Subject: [PATCH 03/22] refactor(ffe): Evaluate flags on OpenFeature types directly Drop the EvaluationContext and EvaluationResult mirrors of the OpenFeature types: the evaluator takes the OpenFeature context and returns a ProviderEvaluation, removing the per-evaluation context adapter and result conversion. Exposures are reported through an ExposureListener at resolution time. Co-Authored-By: Claude Opus 5.5 --- .../evaluation/EvaluationContext.java | 10 -- .../internal/evaluation/EvaluationResult.java | 139 ------------------ .../internal/evaluation/FlagEvaluator.java | 69 ++++++--- 3 files changed, 46 insertions(+), 172 deletions(-) delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java 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 deleted file mode 100644 index 0f4112b2f3d..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java +++ /dev/null @@ -1,10 +0,0 @@ -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/EvaluationResult.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java deleted file mode 100644 index d542171ecb2..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.datadog.openfeature.internal.evaluation; - -import dev.openfeature.sdk.ImmutableMetadata; - -/** 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 ImmutableMetadata 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 ImmutableMetadata 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 ImmutableMetadata 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(ImmutableMetadata 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 index ff5b9bd4558..96fa1f90829 100644 --- 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 @@ -2,8 +2,6 @@ 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; @@ -16,7 +14,12 @@ 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.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -28,30 +31,31 @@ import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; -/** - * Local evaluation over a single configuration snapshot. No transport or OpenFeature dependency. - */ +/** Local evaluation over a single configuration snapshot. No transport 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)); + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.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; + private final ExposureListener exposureListener; - public FlagEvaluator(final boolean spanEnrichmentEnabled) { + public FlagEvaluator( + final boolean spanEnrichmentEnabled, final ExposureListener exposureListener) { this.spanEnrichmentEnabled = spanEnrichmentEnabled; + this.exposureListener = exposureListener; } - public EvaluationResult evaluate( + public ProviderEvaluation 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 + // ProviderEvaluation 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. @@ -79,7 +83,7 @@ public EvaluationResult evaluate( } if (!flag.enabled) { - return EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.DISABLED.name()) .flagMetadata(consentMetadata(observeFullEvaluationData)) @@ -154,7 +158,7 @@ public EvaluationResult evaluate( } } - return EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.DEFAULT.name()) .flagMetadata(consentMetadata(observeFullEvaluationData)) @@ -175,17 +179,17 @@ private static ImmutableMetadata consentMetadata(final boolean observeFullEvalua .build(); } - private static EvaluationResult error( + 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 EvaluationResult.getErrorMessage() — not just our own + // 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 EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.ERROR.name()) .errorCode(code) @@ -386,7 +390,7 @@ private static String getMD5Hash(final String input) { } } - private EvaluationResult resolveVariant( + private ProviderEvaluation resolveVariant( final Class target, final String key, final T defaultValue, @@ -453,8 +457,8 @@ private EvaluationResult resolveVariant( } metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); } - final EvaluationResult result = - EvaluationResult.builder() + final ProviderEvaluation result = + ProviderEvaluation.builder() .value(mappedValue) .reason( !isEmpty(allocation.rules) @@ -464,18 +468,19 @@ private EvaluationResult resolveVariant( : !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(); + if (Boolean.TRUE.equals(allocation.doLog)) { + exposureListener.onExposure(key, result, split.serialId, context); + } return result; } private static Object resolveAttribute(final String name, final EvaluationContext context) { // Special handling for "id" attribute: if not explicitly provided, use targeting key - if ("id".equals(name) && !context.hasAttribute(name)) { + if ("id".equals(name) && !context.keySet().contains(name)) { return context.getTargetingKey(); } - return context.attribute(name); + return context.convertValue(context.getValue(name)); } public static boolean isTypeCompatible(final Class target, final ValueType variationType) { @@ -492,7 +497,7 @@ public static boolean isTypeCompatible(final Class target, final ValueType va case NUMERIC: return target == Double.class; case JSON: - return target == Object.class; + return target == Value.class; default: return true; // Unknown types pass through — mapValue errors caught as GENERAL } @@ -526,7 +531,7 @@ public static T mapValue(final Class target, final Object value) { final Double number = parseDouble(value); return (T) number; } - return (T) value; + return (T) Value.objectToValue(value); } private static Double parseDouble(final Object value) { @@ -536,6 +541,24 @@ private static Double parseDouble(final Object value) { return Double.parseDouble(String.valueOf(value)); } + /** Receives the evaluations whose allocation logs exposures. */ + @FunctionalInterface + public interface ExposureListener { + /** + * Called when an evaluation resolves to an allocation logging exposures. + * + * @param flagKey the evaluated flag key. + * @param evaluation the evaluation result. + * @param serialId the split serial id, or {@code null} if absent. + * @param context the evaluation context. + */ + void onExposure( + String flagKey, + ProviderEvaluation evaluation, + Integer serialId, + EvaluationContext context); + } + @FunctionalInterface private interface NumberComparator { boolean compare(double a, double b); From 3bc8ee17dc518702f44d10819ab570d02093525f Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 12:02:49 +0200 Subject: [PATCH 04/22] fix(ffe): Reject flags with an explicit null enabled field An explicit "enabled": null was read as false, keeping the malformed flag as disabled instead of reporting it invalid. Only an absent field now defaults to false, as with the previous Moshi parsing. Also convert the serial id coercion cases to @TableTest. Co-Authored-By: Claude Opus 5.5 --- .../ufc/UniversalFlagConfigParser.java | 8 +++-- .../ufc/UniversalFlagConfigParserTest.java | 33 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) 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 index fb67e8c19ac..4155de6bd29 100644 --- 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 @@ -188,7 +188,7 @@ private static Flag toFlag(@Nullable final Object value) { final Map json = asObject(value, "flag"); return new Flag( asString(json.get("key"), "key"), - strictBoolean(json.get("enabled"), "enabled"), + strictBoolean(json, "enabled"), asEnum(json.get("variationType"), ValueType.class, "variationType"), toVariants(json.get("variations")), toList(json.get("allocations"), "allocations", UniversalFlagConfigParser::toAllocation)); @@ -333,10 +333,12 @@ private static String asString(@Nullable final Object value, final String name) throw new IllegalArgumentException(name + " is not a string"); } - private static boolean strictBoolean(@Nullable final Object value, final String name) { - if (value == null) { + /** Reads a required boolean field: absent reads {@code false}, explicit {@code null} rejects. */ + private static boolean strictBoolean(final Map json, final String name) { + if (!json.containsKey(name)) { return false; } + final Object value = json.get(name); if (value instanceof Boolean) { return (Boolean) value; } 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 index b4a98c417fb..00b9a757e7c 100644 --- 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 @@ -12,8 +12,6 @@ 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. */ @@ -48,12 +46,19 @@ void rejectsWrongTypedFlagMap() { assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); } - @Test - void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { + @TableTest({ + "scenario | enabled ", + "string | '\"yes\"'", + "number | '1' ", + "null | 'null' " + }) + void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag(final String enabled) throws Exception { final ServerConfiguration config = deserialize( "{\"flags\":{" - + "\"bad\":{\"key\":\"bad\",\"enabled\":\"yes\"}," + + "\"bad\":{\"key\":\"bad\",\"enabled\":" + + enabled + + "}," + "\"good\":{\"key\":\"good\",\"enabled\":true}" + "}}"); @@ -63,6 +68,15 @@ void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { assertTrue(config.flags.get("good").enabled); } + @Test + void readsAbsentEnabledAsDisabled() throws Exception { + final ServerConfiguration config = deserialize("{\"flags\":{\"f\":{\"key\":\"f\"}}}"); + + assertNotNull(config); + assertFalse(config.flags.get("f").enabled); + assertTrue(config.invalidFlags.isEmpty()); + } + @Test void readsVariantNumbersAsDoubles() throws Exception { final ServerConfiguration config = @@ -131,8 +145,13 @@ void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { * 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"}) + @TableTest({ + "scenario | wireValue | expected ", + "numeric string | '\"340132\"' | 340132 ", + "fraction truncated | '1.5' | 1 ", + "negative | '-1' | -1 ", + "overflow saturates | '2147483648' | 2147483647" + }) void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) throws Exception { final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); From 468e041ee82d04c7231e16e1297ea397759b254a Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 12:24:55 +0200 Subject: [PATCH 05/22] fix(ffe): Reject flags with non-string allocation dates A boolean, object or array startAt / endAt was read as null, silently removing the allocation bound. Reject the flag as invalid instead, as with the previous Moshi parsing, while still ignoring numbers and unparsable date strings. Co-Authored-By: Claude Opus 5.5 --- .../ufc/UniversalFlagConfigParser.java | 7 +++- .../ufc/UniversalFlagConfigParserTest.java | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) 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 index 4155de6bd29..3931a9505dc 100644 --- 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 @@ -402,12 +402,15 @@ private static long asIntegral( return (long) number; } - /** Ignores wrongly set dates. */ + /** Ignores unparsable dates and numbers, but rejects other non-string values. */ @Nullable private static Instant toInstant(@Nullable final Object value) { - if (!(value instanceof String)) { + if (value == null || value instanceof Number) { return null; } + if (!(value instanceof String)) { + throw new IllegalArgumentException("allocation date is not a string"); + } try { return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse((String) value, Instant::from); } catch (final RuntimeException e) { 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 index 00b9a757e7c..e22ed9b6dfa 100644 --- 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 @@ -274,6 +274,48 @@ void testInstantParsing(final String value, final String expectedInstant) throws } } + @TableTest({ + "scenario | field | date ", + "boolean start | 'startAt' | 'true' ", + "object start | 'startAt' | '{}' ", + "array start | 'startAt' | '[]' ", + "boolean end | 'endAt' | 'false'", + "object end | 'endAt' | '{}' ", + "array end | 'endAt' | '[]' " + }) + void rejectsFlagWithNonStringAllocationDateAndKeepsSiblingFlag( + final String field, final String date) throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{" + + "\"bad\":{\"key\":\"bad\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"" + + field + + "\":" + + date + + "}]}," + + "\"good\":{\"key\":\"good\",\"enabled\":true}" + + "}}"); + + assertNotNull(config); + assertFalse(config.flags.containsKey("bad")); + assertEquals("invalid_flag", config.invalidFlags.get("bad")); + assertTrue(config.flags.containsKey("good")); + } + + @Test + void readsNumericAllocationDatesAsUnbounded() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"startAt\":1672531200,\"endAt\":1.5}]}}}"); + + final Allocation allocation = config.flags.get("f").allocations.get(0); + assertNull(allocation.startAt); + assertNull(allocation.endAt); + assertTrue(config.invalidFlags.isEmpty()); + } + private static ServerConfiguration deserialize(final String json) throws Exception { return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); } From 33ac491dd92e423cc7a60f71773f9d628f4b550d Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 15:47:29 +0200 Subject: [PATCH 06/22] fix(ffe): Reject configurations with non-finite numbers A number overflowing double, like 1e400, was read as infinity and could resolve as a valid NUMERIC flag value. Reject the configuration instead, as the previous strict Moshi reader did. Co-Authored-By: Claude Opus 5.5 --- .../internal/ufc/UniversalFlagConfigParser.java | 6 +++++- .../internal/ufc/UniversalFlagConfigParserTest.java | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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 index 3931a9505dc..dda46b58394 100644 --- 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 @@ -108,7 +108,11 @@ private static Object readValue(final JsonParser parser) throws IOException { return parser.getText(); case VALUE_NUMBER_INT: case VALUE_NUMBER_FLOAT: - return parser.getDoubleValue(); + final double number = parser.getDoubleValue(); + if (!Double.isFinite(number)) { + throw new JsonParseException(parser, "Non-finite JSON number"); + } + return number; case VALUE_TRUE: return Boolean.TRUE; case VALUE_FALSE: 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 index e22ed9b6dfa..36fd37e9e32 100644 --- 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 @@ -46,6 +46,16 @@ void rejectsWrongTypedFlagMap() { assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); } + @TableTest({ + "scenario | flag ", + "overflowing variant | '{\"key\":\"f\",\"variations\":{\"v\":{\"key\":\"v\",\"value\":1e400}}}' ", + "negative overflow | '{\"key\":\"f\",\"variations\":{\"v\":{\"key\":\"v\",\"value\":-1e400}}}' ", + "overflowing condition | '{\"key\":\"f\",\"allocations\":[{\"key\":\"a\",\"rules\":[{\"conditions\":[{\"value\":1e400}]}]}]}'" + }) + void rejectsNonFiniteNumbers(final String flag) { + assertThrows(IOException.class, () -> deserialize("{\"flags\":{\"f\":" + flag + "}}")); + } + @TableTest({ "scenario | enabled ", "string | '\"yes\"'", From eba9c907f83331a75457590ee803c28b810a32a0 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 09:01:18 +0200 Subject: [PATCH 07/22] feat(ffe): Parse features on UFC splits Port the UFC split features parsing from #12772 to the dd-openfeature SDK parser. Features only feed exposure hooks, so a malformed list or entry is dropped instead of rejecting the flag, and a feature without destinations is dropped. Co-Authored-By: Claude Opus 5.5 --- .../openfeature/internal/ufc/Feature.java | 23 ++++ .../openfeature/internal/ufc/Split.java | 13 +++ .../ufc/UniversalFlagConfigParser.java | 59 +++++++++- .../ufc/UniversalFlagConfigParserTest.java | 106 ++++++++++++++++++ .../remote-config/split-features.json | 35 ++++++ 5 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java create mode 100644 dd-openfeature/src/test/resources/remote-config/split-features.json diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java new file mode 100644 index 00000000000..768f01b72e5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java @@ -0,0 +1,23 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; + +/** Key-value data that the UFC attaches to a split, with where SDKs deliver it. */ +public final class Feature { + public final String key; + + /** A {@link String}, {@link Double} or {@link Boolean}; the parser drops any other value. */ + public final Object value; + + /** + * Open strings such as {@code HOOK}, {@code EXPOSURE} or {@code EVALUATION}. Never empty: the + * parser drops a feature without destinations. + */ + public final List destinations; + + public Feature(final String key, final Object value, final List destinations) { + this.key = key; + this.value = value; + this.destinations = destinations; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java index c4852517adc..0897b77a058 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java @@ -11,14 +11,27 @@ public final class Split { /** Absent in some UFC shapes. Surfaced as {@code __dd_split_serial_id} for span enrichment. */ public final Integer serialId; + /** Null when the UFC split has no features. */ + public final List features; + public Split( final List shards, final String variationKey, final Map extraLogging, final Integer serialId) { + this(shards, variationKey, extraLogging, serialId, null); + } + + public Split( + final List shards, + final String variationKey, + final Map extraLogging, + final Integer serialId, + final List features) { this.shards = shards; this.variationKey = variationKey; this.extraLogging = extraLogging; this.serialId = serialId; + this.features = features; } } diff --git a/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 index dda46b58394..3a699f51553 100644 --- 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 @@ -1,5 +1,8 @@ package com.datadog.openfeature.internal.ufc; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; + import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParser; @@ -267,7 +270,61 @@ private static Split toSplit(@Nullable final Object value) { toList(json.get("shards"), "shards", UniversalFlagConfigParser::toShard), asString(json.get("variationKey"), "variationKey"), toStringMap(json.get("extraLogging")), - serialId == null ? null : asLenientInt(serialId, "serialId")); + serialId == null ? null : asLenientInt(serialId, "serialId"), + toFeatures(json.get("features"))); + } + + /** + * Reads a split's features. Features only feed exposure hooks, so a malformed list or entry is + * dropped instead of rejecting the flag: it must never change which variation a subject gets. + */ + @Nullable + private static List toFeatures(@Nullable final Object value) { + if (!(value instanceof List)) { + return null; + } + final List features = new ArrayList<>(); + for (final Object element : (List) value) { + final Feature feature = toFeature(element); + if (feature != null) { + features.add(feature); + } + } + return unmodifiableList(features); + } + + @Nullable + private static Feature toFeature(@Nullable final Object value) { + if (!(value instanceof Map)) { + return null; + } + final Map json = (Map) value; + final Object key = json.get("key"); + final Object featureValue = json.get("value"); + final List destinations = toDestinations(json.get("destinations")); + if (!(key instanceof String) + || ((String) key).isEmpty() + || !(featureValue instanceof String + || featureValue instanceof Double + || featureValue instanceof Boolean) + || destinations.isEmpty()) { + return null; + } + return new Feature((String) key, featureValue, destinations); + } + + /** Keeps every non-empty string, including destinations this SDK does not know yet. */ + private static List toDestinations(@Nullable final Object value) { + if (!(value instanceof List)) { + return emptyList(); + } + final List destinations = new ArrayList<>(); + for (final Object element : (List) value) { + if (element instanceof String && !((String) element).isEmpty()) { + destinations.add((String) element); + } + } + return unmodifiableList(destinations); } @Nullable 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 index 36fd37e9e32..838282044f2 100644 --- 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 @@ -1,6 +1,7 @@ package com.datadog.openfeature.internal.ufc; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -11,6 +12,7 @@ import java.io.IOException; import java.io.InputStream; import java.time.Instant; +import java.util.List; import org.junit.jupiter.api.Test; import org.tabletest.junit.TableTest; @@ -170,6 +172,110 @@ void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int ex assertEquals(Integer.valueOf(expected), serialIdOf(config)); } + @Test + void parsesSplitFeatures() throws Exception { + final ServerConfiguration config = + deserialize( + configWithFeatures( + "[{\"key\": \"holdout.key\", \"value\": \"q4-global\", \"destinations\": [\"HOOK\"]}," + + " {\"key\": \"holdout.weight\", \"value\": 0.5," + + " \"destinations\": [\"EXPOSURE\", \"EVALUATION\"]}," + + " {\"key\": \"holdout.should_include_in_holdout_analysis\", \"value\": true," + + " \"destinations\": [\"HOOK\", \"SOME_FUTURE_DESTINATION\"]}]")); + + final List features = featuresOf(config); + assertEquals(3, features.size()); + assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); + assertFeature(features.get(1), "holdout.weight", 0.5, "EXPOSURE", "EVALUATION"); + assertFeature( + features.get(2), + "holdout.should_include_in_holdout_analysis", + true, + "HOOK", + "SOME_FUTURE_DESTINATION"); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + @Test + void parsesAbsentSplitFeaturesAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithFeatures(null)); + + assertNull(featuresOf(config)); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + @Test + void dropsMalformedSplitFeaturesAndKeepsTheFlag() throws Exception { + final String hook = ", \"destinations\": [\"HOOK\"]"; + final ServerConfiguration config = + deserialize( + configWithFeatures( + "[{\"key\": \"object-value\", \"value\": {\"nested\": true}" + + hook + + "}," + + " {\"key\": \"array-value\", \"value\": [1]" + + hook + + "}," + + " {\"key\": \"null-value\", \"value\": null" + + hook + + "}," + + " {\"key\": \"\", \"value\": \"empty key\"" + + hook + + "}," + + " {\"key\": 3, \"value\": \"numeric key\"" + + hook + + "}," + + " {\"value\": \"missing key\"" + + hook + + "}," + + " {\"key\": \"missing value\"" + + hook + + "}," + + " {\"key\": \"no-destinations\", \"value\": \"dropped\"}," + + " {\"key\": \"empty-destinations\", \"value\": \"dropped\", \"destinations\": []}," + + " {\"key\": \"destinations-not-a-list\", \"value\": \"dropped\"," + + " \"destinations\": \"HOOK\"}," + + " {\"key\": \"only-invalid-destinations\", \"value\": \"dropped\"," + + " \"destinations\": [1, \"\", null, {\"name\": \"HOOK\"}]}," + + " \"not-an-object\"," + + " {\"key\": \"holdout.key\", \"value\": \"q4-global\"," + + " \"destinations\": [1, \"HOOK\", \"\"], \"destination\": \"HOOK\"}]")); + + assertTrue(config.flags.containsKey("valid-flag")); + final List features = featuresOf(config); + assertEquals(1, features.size()); + assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); + } + + @Test + void ignoresSplitFeaturesThatAreNotAList() throws Exception { + final ServerConfiguration config = + deserialize(configWithFeatures("{\"holdout.key\": \"q4-global\"}")); + + assertTrue(config.flags.containsKey("valid-flag")); + assertNull(featuresOf(config)); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + private static void assertFeature( + final Feature feature, final String key, final Object value, final String... destinations) { + assertEquals(key, feature.key); + assertEquals(value, feature.value); + assertEquals(asList(destinations), feature.destinations); + } + + private static List featuresOf(final ServerConfiguration config) { + return config.flags.get("valid-flag").allocations.get(0).splits.get(0).features; + } + + /** Inserts the raw features JSON; null omits the key. */ + private static String configWithFeatures(final String featuresJson) throws IOException { + final String json = resource("split-features.json"); + return featuresJson == null + ? json.replace("\"features\": \"${features}\",", "") + : json.replace("\"${features}\"", featuresJson); + } + private static Integer serialIdOf(final ServerConfiguration config) { return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; } diff --git a/dd-openfeature/src/test/resources/remote-config/split-features.json b/dd-openfeature/src/test/resources/remote-config/split-features.json new file mode 100644 index 00000000000..d8ff27d02a0 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/split-features.json @@ -0,0 +1,35 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": 7, + "features": "${features}", + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} From de691e466a13426c791ad8c2b3b39bcdf7ffda70 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 17:03:59 +0200 Subject: [PATCH 08/22] fix(ffe): Cap UFC document nesting depth The UFC parser reads the document recursively, including ignored fields, so a deeply nested document could overflow the stack with an error the configuration sources do not catch. Reject documents nested deeper than 32 levels, as Moshi capped them at 255 before. The cap is enforced by the reader rather than Jackson StreamReadConstraints, which jackson-core older than 2.15 (e.g. Spring Boot 2.7) does not provide. Co-Authored-By: Claude Opus 5.5 --- .../ufc/UniversalFlagConfigParser.java | 15 ++++++++--- .../ufc/UniversalFlagConfigParserTest.java | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) 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 index 3a699f51553..2721be5bfef 100644 --- 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 @@ -35,6 +35,9 @@ public final class UniversalFlagConfigParser { private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + /** UFC documents nest about ten levels deep; the cap bounds the recursive document reader. */ + private static final int MAX_NESTING_DEPTH = 32; + private static final JsonFactory JSON_FACTORY = new JsonFactory().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); @@ -82,7 +85,7 @@ private static Object readDocument(final byte[] content) throws IOException { if (parser.nextToken() == null) { throw new JsonParseException(parser, "Empty document"); } - final Object document = readValue(parser); + final Object document = readValue(parser, 1); if (parser.nextToken() != null) { throw new JsonParseException(parser, "Unexpected content after the document"); } @@ -90,21 +93,25 @@ private static Object readDocument(final byte[] content) throws IOException { } } - private static Object readValue(final JsonParser parser) throws IOException { + private static Object readValue(final JsonParser parser, final int depth) throws IOException { final JsonToken token = parser.currentToken(); + if ((token == JsonToken.START_OBJECT || token == JsonToken.START_ARRAY) + && depth > MAX_NESTING_DEPTH) { + throw new JsonParseException(parser, "Document nested deeper than " + MAX_NESTING_DEPTH); + } 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)); + object.put(name, readValue(parser, depth + 1)); } return object; case START_ARRAY: final List array = new ArrayList<>(); while (parser.nextToken() != JsonToken.END_ARRAY) { - array.add(readValue(parser)); + array.add(readValue(parser, depth + 1)); } return array; case VALUE_STRING: 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 index 838282044f2..5afe68fa544 100644 --- 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 @@ -323,6 +323,21 @@ void rejectsTrailingJson() { assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); } + @Test + void acceptsNestingUpToTheLimit() throws Exception { + assertNotNull(deserialize(nestedConfig(31))); + } + + @Test + void rejectsNestingBeyondTheLimit() { + assertThrows(IOException.class, () -> deserialize(nestedConfig(32))); + assertThrows( + IOException.class, + () -> + UniversalFlagConfigParser.parseJsonApi( + ("{\"data\":" + nestedConfig(32) + "}").getBytes(UTF_8))); + } + @Test void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { final ServerConfiguration config = deserialize(resource("unknown-operator.json")); @@ -436,6 +451,18 @@ private static ServerConfiguration deserialize(final String json) throws Excepti return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); } + private static String nestedConfig(final int arrays) { + final StringBuilder json = new StringBuilder("{\"flags\":{},\"ignored\":"); + for (int i = 0; i < arrays; i++) { + json.append('['); + } + json.append('0'); + for (int i = 0; i < arrays; i++) { + json.append(']'); + } + return json.append('}').toString(); + } + private static String emptyConfig() throws IOException { return resource("empty-config.json"); } From 9220437412f56b6a753fdb4a1a1ff17bb4024aae Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 09:06:03 +0200 Subject: [PATCH 09/22] feat(ffe): Add dd-openfeature SDK settings and configuration delivery Add the SDK settings, the connector SPI the agent can implement, the agentless configuration delivery from the CDN and the direct intake event transport. Co-Authored-By: Claude Opus 5.5 --- dd-openfeature/build.gradle.kts | 1 + dd-openfeature/gradle.lockfile | 79 +-- .../internal/ConfigurationService.java | 18 + .../openfeature/internal/EventQueue.java | 107 ++++ .../openfeature/internal/JsonWriting.java | 96 ++++ .../openfeature/internal/RuntimeServices.java | 40 ++ .../internal/config/SdkVersion.java | 31 ++ .../openfeature/internal/config/Settings.java | 249 ++++++++++ .../connector/ConfigurationSource.java | 15 + .../internal/connector/Connector.java | 41 ++ .../internal/connector/Connectors.java | 16 + .../EventProxyUnavailableException.java | 13 + .../internal/connector/EventTransport.java | 16 + .../internal/connector/HealthMetrics.java | 19 + .../internal/connector/NoConnector.java | 33 ++ .../internal/connector/SpanEnricher.java | 34 ++ .../internal/connector/package-info.java | 8 + .../delivery/CdnConfigurationSource.java | 323 ++++++++++++ .../delivery/DirectIntakeTransport.java | 93 ++++ .../internal/delivery/FallbackTransport.java | 39 ++ .../openfeature/internal/delivery/Gzip.java | 34 ++ .../internal/delivery/Headers.java | 27 + .../delivery/RemoteConfigurationService.java | 57 +++ .../internal/delivery/Retries.java | 75 +++ .../openfeature/internal/EventQueueTest.java | 129 +++++ .../openfeature/internal/JsonReading.java | 101 ++++ .../openfeature/internal/LocalHttpServer.java | 163 +++++++ .../internal/config/SettingsTest.java | 168 +++++++ .../internal/config/TestSettings.java | 31 ++ .../delivery/CdnConfigurationSourceTest.java | 461 ++++++++++++++++++ .../delivery/DirectIntakeTransportTest.java | 121 +++++ .../delivery/FallbackTransportTest.java | 52 ++ 32 files changed, 2654 insertions(+), 36 deletions(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/JsonWriting.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Gzip.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/JsonReading.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/LocalHttpServer.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts index 17e9f6ee412..c218e9603d7 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -5,6 +5,7 @@ plugins { `java-library` idea id("dd-trace-java.conventions.java") + id("dd-trace-java.version-file") } description = "Datadog OpenFeature provider SDK." diff --git a/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile index c878fe4af10..8fa9448ffd5 100644 --- a/dd-openfeature/gradle.lockfile +++ b/dd-openfeature/gradle.lockfile @@ -2,77 +2,84 @@ # 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 +ch.qos.logback:logback-classic:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,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-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,spotbugs,testCompileClasspath,testRuntimeClasspath +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.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 +de.thetaphi:forbiddenapis:3.11=compileClasspath,jmhCompileClasspath +dev.openfeature:sdk:1.22.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.leangen.geantyref:geantyref:1.3.16=jmhRuntimeClasspath,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.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,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.groovy:groovy-ant:3.0.23=codenarc org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc org.codehaus.groovy:groovy-json:3.0.23=codenarc -org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +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=testCompileClasspath,testRuntimeClasspath +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=testCompileClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt org.jacoco:org.jacoco.core:0.8.15=jacocoAnt org.jacoco:org.jacoco.report:0.8.15=jacocoAnt -org.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.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=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath -org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jcl-over-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j -org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,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.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,spotbugsPlugins,testAnnotationProcessor +empty=annotationProcessor,jmhAnnotationProcessor,spotbugsPlugins,testAnnotationProcessor 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/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..f2ae167b055 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -0,0 +1,323 @@ +package com.datadog.openfeature.internal.delivery; + +import static com.datadog.openfeature.internal.delivery.Retries.isRetryableStatus; + +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 Retries.Sleeper 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 Retries.Sleeper 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 { + return Retries.send( + this.httpClient, + buildRequest(), + HttpResponse.BodyHandlers.ofByteArray(), + MAX_ATTEMPTS, + attempt -> retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble()), + this.retrySleeper, + () -> this.closed); + } + + 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 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)); + } +} 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..e20fb556a37 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java @@ -0,0 +1,93 @@ +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 Retries.Sleeper retrySleeper; + + DirectIntakeTransport( + final URI intake, + final String apiKey, + final HttpClient httpClient, + final Retries.Sleeper 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(); + final int status; + try { + status = + Retries.send( + this.httpClient, + request, + HttpResponse.BodyHandlers.discarding(), + MAX_RETRIES + 1, + attempt -> INITIAL_RETRY_DELAY_MILLIS << (attempt - 1), + this.retrySleeper, + () -> false) + .statusCode(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request to " + route + " was interrupted", e); + } + if (status < 200 || status >= 300) { + throw new IOException("Request to " + route + " returned error response " + status); + } + } +} 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/delivery/Retries.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java new file mode 100644 index 00000000000..fd6e22b7ee9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java @@ -0,0 +1,75 @@ +package com.datadog.openfeature.internal.delivery; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.function.BooleanSupplier; +import java.util.function.IntToLongFunction; + +/** Sends HTTP requests, retrying the failures that can succeed on a later attempt. */ +final class Retries { + private Retries() {} + + /** + * Sends a request until it gets a non-retryable response, the attempts are exhausted, or the + * caller gives up. + * + * @param httpClient the client to send the request with. + * @param request the request to send. + * @param bodyHandler the response body handler. + * @param maxAttempts the maximum number of attempts. + * @param retryDelayMillis the delay before the next attempt, given the failed attempt number. + * @param sleeper the sleeper waiting between attempts. + * @param cancelled whether the caller gave up on the request. + * @param the response body type. + * @return the last response, which can still have a retryable status. + * @throws IOException if the last attempt failed. + * @throws InterruptedException if interrupted while sending or waiting between attempts. + */ + static HttpResponse send( + final HttpClient httpClient, + final HttpRequest request, + final HttpResponse.BodyHandler bodyHandler, + final int maxAttempts, + final IntToLongFunction retryDelayMillis, + final Sleeper sleeper, + final BooleanSupplier cancelled) + throws IOException, InterruptedException { + int attempt = 0; + while (true) { + attempt++; + try { + final HttpResponse response = httpClient.send(request, bodyHandler); + if (!isRetryableStatus(response.statusCode()) + || attempt == maxAttempts + || cancelled.getAsBoolean()) { + return response; + } + } catch (final IOException e) { + if (attempt == maxAttempts + || cancelled.getAsBoolean() + || Thread.currentThread().isInterrupted()) { + throw e; + } + } + sleeper.sleep(retryDelayMillis.applyAsLong(attempt)); + } + } + + /** + * @param status the HTTP response status. + * @return whether a request that got this status can succeed on a later attempt. + */ + static boolean isRetryableStatus(final int status) { + return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT + || status == 429 + || (status >= 500 && status <= 599); + } + + @FunctionalInterface + interface Sleeper { + void sleep(long delayMillis) throws InterruptedException; + } +} 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/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..465dbad7086 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java @@ -0,0 +1,121 @@ +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", + "client timeout | 408 ", + "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); + } +} From 49352d753e8ea298113b0c4fd6e9b34655022f2a Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 07:56:12 +0200 Subject: [PATCH 10/22] refactor(ffe): Parse the configuration source like the agent Parse the Feature Flags configuration source like the agent enum config parsing: case insensitive, with no trimming of surrounding spaces, so the SDK and the agent keep resolving the same configuration. Co-Authored-By: Claude Opus 5.5 --- .../openfeature/internal/config/Settings.java | 11 ++++++- .../internal/config/SettingsTest.java | 29 ++++++++++--------- 2 files changed, 25 insertions(+), 15 deletions(-) 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 index a175b5d43bf..0006e95d548 100644 --- 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 @@ -90,7 +90,7 @@ public String configurationSource() { @Nullable private String resolveSource() { final Boolean enabled = getBoolean(FEATURE_FLAGS_ENABLED); - final String source = normalize(get(FEATURE_FLAGS_CONFIGURATION_SOURCE)); + final String source = normalizeSource(get(FEATURE_FLAGS_CONFIGURATION_SOURCE)); if (Boolean.FALSE.equals(enabled)) { return null; } @@ -238,6 +238,15 @@ private static String normalize(@Nullable final String value) { return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT); } + /** + * Normalizes the configuration source like the agent enum parsing: case insensitive, with no + * trimming of surrounding spaces. + */ + @Nullable + private static String normalizeSource(@Nullable final String value) { + return nonBlank(value) == null ? null : value.replace('/', '_').toLowerCase(Locale.ROOT); + } + @Nullable private static String nonBlank(@Nullable final String value) { if (value == null) { 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 index c2774b4c9a9..cf1f09f18dd 100644 --- 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 @@ -44,20 +44,21 @@ void mapsKeysToEnvironmentVariables(final String key, final String variable) { } @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' | | | " + "scenario | enabled | source | legacy | resolved ", + "default | | | | 'agentless' ", + "explicit agentless | | 'agentless' | | 'agentless' ", + "explicit remote config | | 'remote_config' | | 'remote_config'", + "case insensitive source | | 'Remote_Config' | | 'remote_config'", + "padded source | | ' agentless ' | | ", + "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) { From 678eb361711bf96313e070101d31edbee40fe6d7 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 15:53:20 +0200 Subject: [PATCH 11/22] fix(ffe): Validate Feature Flags endpoint URLs Restore the endpoint validation the agent URL builders provided: - reject custom configuration URLs with a scheme other than http(s), - keep the custom URL query when adding the default configuration path, - reject a site that does not make a plain host name, so the API key cannot be sent to another host. Co-Authored-By: Claude Opus 5.5 --- .../delivery/CdnConfigurationSource.java | 10 ++++-- .../delivery/DirectIntakeTransport.java | 3 +- .../internal/delivery/SiteHost.java | 30 ++++++++++++++++++ .../delivery/CdnConfigurationSourceTest.java | 30 ++++++++++++------ .../delivery/DirectIntakeTransportTest.java | 9 ++++++ .../internal/delivery/SiteHostTest.java | 31 +++++++++++++++++++ 6 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/SiteHost.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/SiteHostTest.java 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 index f2ae167b055..167b59a2217 100644 --- 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 @@ -285,13 +285,17 @@ static URI endpoint(final Settings settings) { throw new IllegalArgumentException( "Invalid Feature Flags configuration source URL: " + configuredBaseUrl, e); } - if (parsed.getScheme() == null || parsed.getHost() == null) { + final String scheme = parsed.getScheme(); + if ((!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) + || 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); + final String query = parsed.getRawQuery(); + return parsed.resolve( + query == null ? UFC_RULES_BASED_SERVER_PATH : UFC_RULES_BASED_SERVER_PATH + "?" + query); } return parsed; } @@ -301,7 +305,7 @@ private static boolean isManagedEndpoint(final Settings settings) { } private static String managedHost(final Settings settings) { - return "ufc-server.ff-cdn." + settings.site(); + return SiteHost.of("ufc-server.ff-cdn", settings.site()); } static long retryDelayMillis( 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 index e20fb556a37..e77de40a0dd 100644 --- 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 @@ -45,6 +45,7 @@ public final class DirectIntakeTransport implements EventTransport { * * @param settings the SDK settings. * @return the transport, or {@code null} if no API key is configured. + * @throws IllegalArgumentException if the configured site is invalid. */ @Nullable public static DirectIntakeTransport create(final Settings settings) { @@ -53,7 +54,7 @@ public static DirectIntakeTransport create(final Settings settings) { return null; } return new DirectIntakeTransport( - URI.create("https://event-platform-intake." + settings.site() + "/api/v2/"), + URI.create("https://" + SiteHost.of("event-platform-intake", settings.site()) + "/api/v2/"), apiKey, HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).followRedirects(NEVER).build(), TimeUnit.MILLISECONDS::sleep); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/SiteHost.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/SiteHost.java new file mode 100644 index 00000000000..f12639c3ad2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/SiteHost.java @@ -0,0 +1,30 @@ +package com.datadog.openfeature.internal.delivery; + +import java.net.URI; +import java.net.URISyntaxException; + +/** Builds the host of a Datadog endpoint from the configured site. */ +final class SiteHost { + private SiteHost() {} + + /** + * Builds the host of a Datadog endpoint. + * + * @param prefix the endpoint host prefix. + * @param site the configured Datadog site. + * @return the endpoint host. + * @throws IllegalArgumentException if the site does not make a plain host name. + */ + static String of(final String prefix, final String site) { + final String host = prefix + "." + site; + try { + final URI uri = new URI("https", null, host, -1, "/", null, null); + if (host.equalsIgnoreCase(uri.getHost())) { + return host; + } + } catch (final URISyntaxException ignored) { + // Reported below + } + throw new IllegalArgumentException("Invalid Datadog site: " + site); + } +} 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 index 6e624d7352c..055293f6f6e 100644 --- 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 @@ -84,12 +84,23 @@ void encodesEnvInDatadogUfcCdnEndpoint() { CdnConfigurationSource.endpoint(TestSettings.of("env", "a b&c")).toString()); } + @Test + void rejectsInvalidSiteForDatadogUfcCdnEndpoint() { + assertThrows( + IllegalArgumentException.class, + () -> + CdnConfigurationSource.endpoint( + TestSettings.of("site", "datadoghq.com@other.example"))); + } + @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' " + "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' ", + "root query | 'https://proxy.example/?token=a%2Bb' | 'https://proxy.example/api/v2/feature-flagging/config/rules-based/server?token=a%2Bb'", + "host query | 'https://proxy.example?token=a' | 'https://proxy.example/api/v2/feature-flagging/config/rules-based/server?token=a' ", + "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( @@ -104,10 +115,11 @@ void resolvesConfiguredAgentlessBaseUrl(final String baseUrl, final String endpo } @TableTest({ - "scenario | baseUrl ", - "no scheme | 'localhost' ", - "relative path | 'relative/ufc'", - "invalid | 'http://[::1' " + "scenario | baseUrl ", + "no scheme | 'localhost' ", + "relative path | 'relative/ufc' ", + "invalid | 'http://[::1' ", + "ftp scheme | 'ftp://example.com/ufc'" }) void rejectsInvalidConfiguredAgentlessBaseUrl(final String baseUrl) { final Settings settings = 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 index 465dbad7086..12ef8ec0d34 100644 --- 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 @@ -40,6 +40,15 @@ void requiresAnApiKey() { assertNotNull(DirectIntakeTransport.create(TestSettings.of("api-key", "secret"))); } + @Test + void rejectsInvalidSite() { + assertThrows( + IllegalArgumentException.class, + () -> + DirectIntakeTransport.create( + TestSettings.of("api-key", "secret", "site", "datadoghq.com@other.example"))); + } + @Test void postsJsonWithApiKeyAndTraceHeaders() throws Exception { this.server.enqueue(202, null); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/SiteHostTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/SiteHostTest.java new file mode 100644 index 00000000000..b47e3558004 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/SiteHostTest.java @@ -0,0 +1,31 @@ +package com.datadog.openfeature.internal.delivery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.tabletest.junit.TableTest; + +class SiteHostTest { + @TableTest({ + "scenario | site | host ", + "default | 'datadoghq.com' | 'intake.datadoghq.com' ", + "eu | 'datadoghq.eu' | 'intake.datadoghq.eu' ", + "subdomain | 'us3.datadoghq.com' | 'intake.us3.datadoghq.com'" + }) + void buildsHostFromSite(final String site, final String host) { + assertEquals(host, SiteHost.of("intake", site)); + } + + @TableTest({ + "scenario | site ", + "user info | 'datadoghq.com@other.example'", + "path | 'datadoghq.com/other' ", + "fragment | 'datadoghq.com#' ", + "port | 'datadoghq.com:8443' ", + "query | 'datadoghq.com?other' ", + "space | 'datadoghq com' " + }) + void rejectsSiteThatIsNotAHostName(final String site) { + assertThrows(IllegalArgumentException.class, () -> SiteHost.of("intake", site)); + } +} From 2e0d53c9900d1a62c21f9fee206b29af8fc462d0 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 16:09:11 +0200 Subject: [PATCH 12/22] fix(ffe): Parse boolean and integer settings like the agent Read boolean and integer settings from their raw value instead of a trimmed one: the agent reads a padded boolean as false and ignores a padded integer. Also skip blank values and integers that do not parse in favor of the next source, like the agent, so the SDK resolves the same settings with or without the agent attached. Co-Authored-By: Claude Opus 5.5 --- .../openfeature/internal/config/Settings.java | 71 ++++++++++++------- .../internal/config/SettingsTest.java | 59 +++++++++------ 2 files changed, 83 insertions(+), 47 deletions(-) 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 index 0006e95d548..7dbab1cbd2f 100644 --- 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 @@ -15,6 +15,9 @@ public final class Settings { private static final Logger LOGGER = LoggerFactory.getLogger(Settings.class); + /** The setting sources: connector, system properties, then environment variables. */ + private static final int SOURCE_COUNT = 3; + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; @@ -186,58 +189,74 @@ public String version() { return nonBlank(get(VERSION)); } + /** Reads the first non-blank value, in source priority order, like the agent. */ @Nullable String get(final String key) { - String value = this.connector.setting(key); - if (value == null) { - value = this.systemProperties.apply("dd." + key); + for (int source = 0; source < SOURCE_COUNT; source++) { + final String value = value(key, source); + if (nonBlank(value) != null) { + return value; + } } - if (value == null) { - value = this.environment.apply(environmentVariableName(key)); + return null; + } + + /** + * Reads a setting from one source. + * + * @param key the setting key. + * @param source the source index, from the highest priority one. + * @return the raw setting value, or {@code null} if not set. + */ + @Nullable + private String value(final String key, final int source) { + switch (source) { + case 0: + return this.connector.setting(key); + case 1: + return this.systemProperties.apply("dd." + key); + default: + return this.environment.apply(environmentVariableName(key)); } - return value; } @Nullable private Boolean getBoolean(final String key) { - final String value = normalize(get(key)); + final String value = 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)) { + // Same parsing as the agent: exact values, invalid ones are read as false. + if ("1".equals(value) || "true".equalsIgnoreCase(value)) { return Boolean.TRUE; } - if (!"false".equals(value) && !"0".equals(value)) { + if (!"0".equals(value) && !"false".equalsIgnoreCase(value)) { LOGGER.warn("Invalid boolean value for {}: {}", key, value); } return Boolean.FALSE; } + /** Reads the first value that parses, in source priority order, like the agent. */ 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; + for (int source = 0; source < SOURCE_COUNT; source++) { + final String value = value(key, source); + if (nonBlank(value) == null) { + continue; + } + 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); - } - /** * Normalizes the configuration source like the agent enum parsing: case insensitive, with no * trimming of surrounding spaces. 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 index cf1f09f18dd..2cab13382c7 100644 --- 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 @@ -44,21 +44,23 @@ void mapsKeysToEnvironmentVariables(final String key, final String variable) { } @TableTest({ - "scenario | enabled | source | legacy | resolved ", - "default | | | | 'agentless' ", - "explicit agentless | | 'agentless' | | 'agentless' ", - "explicit remote config | | 'remote_config' | | 'remote_config'", - "case insensitive source | | 'Remote_Config' | | 'remote_config'", - "padded source | | ' agentless ' | | ", - "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' | | | " + "scenario | enabled | source | legacy | resolved ", + "default | | | | 'agentless' ", + "explicit agentless | | 'agentless' | | 'agentless' ", + "explicit remote config | | 'remote_config' | | 'remote_config'", + "case insensitive source | | 'Remote_Config' | | 'remote_config'", + "padded source | | ' agentless ' | | ", + "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' | | | ", + "padded enabled reads as false | ' true ' | | | ", + "padded legacy reads as false | | | ' true ' | " }) void resolvesConfigurationSource( final String enabled, final String source, final String legacy, final String resolved) { @@ -88,12 +90,13 @@ void appliesDefaults() { } @TableTest({ - "scenario | value | interval", - "valid | '10' | 10 ", - "zero | '0' | 30 ", - "negative | '-5' | 30 ", - "invalid | 'ten' | 30 ", - "blank | ' ' | 30 " + "scenario | value | interval", + "valid | '10' | 10 ", + "zero | '0' | 30 ", + "negative | '-5' | 30 ", + "invalid | 'ten' | 30 ", + "blank | ' ' | 30 ", + "padded | ' 10 ' | 30 " }) void fallsBackToDefaultPollIntervalOnInvalidValues(final String value, final int interval) { assertEquals( @@ -102,6 +105,20 @@ void fallsBackToDefaultPollIntervalOnInvalidValues(final String value, final int .agentlessPollIntervalSeconds()); } + @Test + void fallsBackToLowerPrioritySourcesOnBlankOrInvalidValues() { + final Map properties = new HashMap<>(); + properties.put("dd.flagging.evaluation.counts.enabled", " "); + properties.put("dd.feature.flags.configuration.source.agentless.poll.interval.seconds", "ten"); + final Map environment = new HashMap<>(); + environment.put("DD_FLAGGING_EVALUATION_COUNTS_ENABLED", "false"); + environment.put("DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS", "10"); + final Settings settings = new Settings(Connector.NONE, properties::get, environment::get); + + assertFalse(settings.isEvaluationCountsEnabled()); + assertEquals(10, settings.agentlessPollIntervalSeconds()); + } + @Test void trimsAndIgnoresBlankValues() { final Settings settings = From 708b4b5f7833afdb1da45a7bb5278dce4d886863 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 16:09:12 +0200 Subject: [PATCH 13/22] fix(ffe): Report only parsing failures as malformed Remote Configuration Keep the configuration listener call out of the parsing error handling, so a listener failure is not logged as a malformed configuration. Co-Authored-By: Claude Opus 5.5 --- .../internal/delivery/RemoteConfigurationService.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index 04c0722e9a8..8ed5f9ce4f1 100644 --- 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 @@ -32,13 +32,15 @@ public synchronized void start(final Consumer listener) { listener.accept(null); return; } + final ServerConfiguration configuration; try { - final ServerConfiguration configuration = UniversalFlagConfigParser.parse(content); - if (configuration != null) { - listener.accept(configuration); - } + configuration = UniversalFlagConfigParser.parse(content); } catch (final IOException | RuntimeException e) { LOGGER.debug("Remote Configuration returned a malformed flag configuration", e); + return; + } + if (configuration != null) { + listener.accept(configuration); } }); } From 2a64150c2522cf23e5dbc243fdfd5ce1452c28a5 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 09:07:11 +0200 Subject: [PATCH 14/22] feat(ffe): Add dd-openfeature SDK exposure and flag evaluation pipelines Add the exposure and flag evaluation event pipelines: deduplication, aggregation, payload building and batching over the event transport. Co-Authored-By: Claude Opus 5.5 --- dd-openfeature/build.gradle.kts | 5 + ...gEvaluationEnqueueContentionBenchmark.java | 189 ++++++ .../FlagEvaluationHotPathBenchmark.java | 160 ++++++ .../internal/exposure/Allocation.java | 9 + .../internal/exposure/ExposureCache.java | 64 +++ .../internal/exposure/ExposureEvent.java | 26 + .../internal/exposure/ExposurePipeline.java | 164 ++++++ .../internal/exposure/ExposuresRequest.java | 53 ++ .../openfeature/internal/exposure/Flag.java | 9 + .../internal/exposure/LRUExposureCache.java | 41 ++ .../internal/exposure/Subject.java | 13 + .../internal/exposure/Variant.java | 9 + .../flagevaluation/FlagEvalEvent.java | 98 ++++ .../FlagEvaluationAggregator.java | 463 +++++++++++++++ .../FlagEvaluationPayloads.java | 338 +++++++++++ .../FlagEvaluationPipeline.java | 543 ++++++++++++++++++ .../flagevaluation/FlagEvaluationWriter.java | 33 ++ .../exposure/ExposurePipelineTest.java | 299 ++++++++++ .../exposure/ExposuresRequestTest.java | 62 ++ .../exposure/LRUExposureCacheTest.java | 308 ++++++++++ .../FlagEvaluationAggregatorTest.java | 424 ++++++++++++++ .../FlagEvaluationPayloadsTest.java | 430 ++++++++++++++ .../FlagEvaluationPipelineTest.java | 484 ++++++++++++++++ .../FlagEvaluationTestSupport.java | 226 ++++++++ 24 files changed, 4450 insertions(+) create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts index c218e9603d7..f583a0cc81d 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -6,6 +6,7 @@ plugins { idea id("dd-trace-java.conventions.java") id("dd-trace-java.version-file") + id("dd-trace-java.jmh-conventions") } description = "Datadog OpenFeature provider SDK." @@ -37,3 +38,7 @@ dependencies { tasks.withType().configureEach { configureCompiler(JavaVersion.VERSION_11) } + +jmh { + jmhVersion = libs.versions.jmh.get() +} diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java new file mode 100644 index 00000000000..b862c1388f9 --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java @@ -0,0 +1,189 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +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; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Group; +import org.openjdk.jmh.annotations.GroupThreads; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Producer-contention benchmark for {@link 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 ({@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 + * into {@code AtomicLong} contention on the overflow counter instead of the enqueue path. The + * consumer polls up to {@link #DRAIN_BATCH} events per invocation to keep the queue off its + * capacity limit. Overflow is reported at the end of each iteration so a saturated - and therefore + * invalid - run is visible rather than silent. + * + *

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

Scope limitation: this benchmark starts from a pre-built FlagEvalEvent + * holding a ready-made flat attribute map. It therefore does NOT cover the OpenFeature hook's own + * inline capture cost - in particular the bounded evaluation-context copy that + * FlagEvalLoggingHook.finallyAfter performs synchronously on the evaluation thread under + * consent-on. That cost is measured separately by FlagEvalHookHotPathBenchmark in 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 :dd-openfeature:jmh -Pjmh.includes=FlagEvaluationHotPathBenchmark}. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 1) +public class FlagEvaluationHotPathBenchmark { + + @Param({ + "typical/100flags_50users_10fields", + "stress/10flags_1000users_250fields", + "scale/2500flags_500users_20fields" + }) + public String profile; + + private Map attrs; + private String[] flagKeys; + private String[] targetingKeys; + private int cursor; + private FlagEvaluationPipeline writer; + private FlagEvaluationPipeline.SerializingHandlerForTest handler; + + @Setup(Level.Iteration) + public void setUp() { + final Profile p = Profile.fromName(profile); + + attrs = new HashMap<>(); + for (int i = 0; i < p.numFields; i++) { + attrs.put("field" + i, "value"); + } + flagKeys = keys("bench-flag-", p.numFlags); + targetingKeys = keys("bench-user-", p.numUsers); + cursor = 0; + + final EventTransport transport = (route, json) -> {}; + final RuntimeServices services = new RuntimeServices(HealthMetrics.NOOP); + final Map ddContext = new HashMap<>(); + ddContext.put("service", "bench-service"); + handler = new FlagEvaluationPipeline.SerializingHandlerForTest(transport, ddContext, services); + + // Capacity large enough that the benchmark never overflows within a measurement window. + writer = + new FlagEvaluationPipeline( + 1 << 20, Long.MAX_VALUE, NANOSECONDS, transport, ddContext, services); + } + + /** + * Queue-mechanics cost only: event allocation plus the non-blocking bounded-queue offer. Excludes + * the hook's inline context snapshot - see the class javadoc. + */ + @Benchmark + public void writerEnqueue(final Blackhole blackhole) { + final FlagEvalEvent event = nextEvent(); + writer.enqueue(event); + blackhole.consume(writer.pollQueuedEventForTest()); + blackhole.consume(event); + } + + /** Worker-thread cost: materialize context, prune, canonicalize, and aggregate. */ + @Benchmark + public void workerAggregate(final Blackhole blackhole) { + final FlagEvalEvent event = nextEvent(); + handler.aggregateEvent(event); + if ((cursor % 10_000) == 0) { + handler.clearAggregationForTest(); + } + blackhole.consume(handler.fullTierSizeForTest()); + } + + private FlagEvalEvent nextEvent() { + final int i = cursor++; + // observeFullEvaluationData=true is required for this benchmark to mean anything: under the + // consent-off default the aggregator drops attrs and skips canonicalContextKey entirely, so + // every field-count profile would collapse to the same scalar-only cost. + return new FlagEvalEvent( + flagKeys[Math.floorMod(i, flagKeys.length)], + "variant-" + Math.floorMod(i, 4), + "alloc-" + Math.floorMod(i, flagKeys.length), + targetingKeys[Math.floorMod(i, targetingKeys.length)], + null, + 1_700_000_000_000L + i, + true, + attrs); + } + + private static String[] keys(final String prefix, final int count) { + final String[] out = new String[count]; + for (int i = 0; i < count; i++) { + out[i] = prefix + i; + } + return out; + } + + private static final class Profile { + private final int numFlags; + private final int numUsers; + private final int numFields; + + private Profile(final int numFlags, final int numUsers, final int numFields) { + this.numFlags = numFlags; + this.numUsers = numUsers; + this.numFields = numFields; + } + + private static Profile fromName(final String name) { + if ("typical/100flags_50users_10fields".equals(name)) { + return new Profile(100, 50, 10); + } + if ("stress/10flags_1000users_250fields".equals(name)) { + return new Profile(10, 1_000, 250); + } + if ("scale/2500flags_500users_20fields".equals(name)) { + return new Profile(2_500, 500, 20); + } + throw new IllegalArgumentException("unknown benchmark profile: " + name); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java new file mode 100644 index 00000000000..a4d40e9e3c3 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.exposure; + +public class Allocation { + public final String key; + + public Allocation(final String key) { + this.key = key; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java new file mode 100644 index 00000000000..0ce6e6c31d9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java @@ -0,0 +1,64 @@ +package com.datadog.openfeature.internal.exposure; + +import java.util.Objects; + +public interface ExposureCache { + + boolean add(ExposureEvent event); + + Value get(Key key); + + int size(); + + final class Key { + public final String flag; + public final String subject; + + public Key(final ExposureEvent event) { + this.flag = event.flag == null ? null : event.flag.key; + this.subject = event.subject == null ? null : event.subject.id; + } + + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final Key key = (Key) o; + return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); + } + + @Override + public int hashCode() { + return Objects.hash(flag, subject); + } + } + + final class Value { + public final String variant; + public final String allocation; + public final Integer serialId; + + public Value(final ExposureEvent event) { + this.variant = event.variant == null ? null : event.variant.key; + this.allocation = event.allocation == null ? null : event.allocation.key; + this.serialId = event.serial_id; + } + + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final Value value = (Value) o; + return Objects.equals(variant, value.variant) + && Objects.equals(allocation, value.allocation) + && Objects.equals(serialId, value.serialId); + } + + @Override + public int hashCode() { + return Objects.hash(variant, allocation, serialId); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java new file mode 100644 index 00000000000..a49811908ba --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java @@ -0,0 +1,26 @@ +package com.datadog.openfeature.internal.exposure; + +public class ExposureEvent { + // milliseconds since epoch as given by System.currentTimeMillis() + public final long timestamp; + public final Allocation allocation; + public final Flag flag; + public final Variant variant; + public final Subject subject; + public final Integer serial_id; + + public ExposureEvent( + final long timestamp, + final Allocation allocation, + final Flag flag, + final Variant variant, + final Subject subject, + final Integer serialId) { + this.timestamp = timestamp; + this.allocation = allocation; + this.flag = flag; + this.variant = variant; + this.subject = subject; + this.serial_id = serialId; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java new file mode 100644 index 00000000000..c0385845ce2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java @@ -0,0 +1,164 @@ +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; + +/** 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(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 EventQueue queue; + private final Thread serializerThread; + + public ExposurePipeline( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this.queue = new EventQueue<>(capacity); + final ExposureSerializingHandler serializer = + new ExposureSerializingHandler(transport, queue, flushInterval, timeUnit, context); + this.serializerThread = services.newThread("exposures", serializer); + } + + /** Starts the background serializing thread. */ + public void start() { + this.serializerThread.start(); + } + + @Override + public void close() { + if (this.serializerThread.isAlive()) { + this.serializerThread.interrupt(); + } + } + + @Override + public void accept(final ExposureEvent event) { + queue.offer(event); + } + + boolean isSerializerThreadAlive() { + return serializerThread.isAlive(); + } + + int queueSize() { + return queue.size(); + } + + private static class ExposureSerializingHandler implements Runnable { + private final EventQueue queue; + private final long ticksRequiredToFlush; + private long lastTicks; + + private final EventTransport transport; + private final Map context; + private final ExposureCache cache; + + private final List buffer = new ArrayList<>(); + + ExposureSerializingHandler( + final EventTransport transport, + final EventQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context) { + this.queue = queue; + this.cache = new LRUExposureCache(queue.capacity()); + this.transport = transport; + this.context = context; + + this.lastTicks = System.nanoTime(); + this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); + + LOGGER.debug("starting exposure serializer"); + } + + @Override + public void run() { + try { + runDutyCycle(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + LOGGER.debug("exposure processor worker exited. submitting exposures stopped."); + } + + private void runDutyCycle() throws InterruptedException { + final Thread thread = Thread.currentThread(); + while (!thread.isInterrupted()) { + ExposureEvent event; + while ((event = queue.poll(100, TimeUnit.MILLISECONDS)) != null) { + if (addToBuffer(event)) { + consumeBatch(); + break; + } + } + flushIfNecessary(); + } + } + + private void consumeBatch() { + queue.drain(this::addToBuffer, queue.size()); + } + + /** Adds an element to the buffer taking care of duplicated exposures thanks to the LRU cache */ + private boolean addToBuffer(final ExposureEvent event) { + if (cache.add(event)) { + buffer.add(event); + return true; + } + return false; + } + + protected void flushIfNecessary() { + if (buffer.isEmpty()) { + return; + } + if (shouldFlush()) { + final byte[] payload; + try { + final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); + payload = exposures.serialize(); + } catch (RuntimeException e) { + LOGGER.error("Could not serialize exposures; dropping batch", e); + this.buffer.clear(); + return; + } + try { + 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. + this.buffer.clear(); + } + } + } + + private boolean shouldFlush() { + long nanoTime = System.nanoTime(); + long ticks = nanoTime - lastTicks; + if (ticks > ticksRequiredToFlush || queue.size() >= FLUSH_THRESHOLD) { + lastTicks = nanoTime; + return true; + } + return false; + } + } +} 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/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java new file mode 100644 index 00000000000..0085cfe4d8a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.exposure; + +public class Flag { + public final String key; + + public Flag(final String key) { + this.key = key; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java new file mode 100644 index 00000000000..8a83e6df745 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java @@ -0,0 +1,41 @@ +package com.datadog.openfeature.internal.exposure; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * This class is intentionally not thread-safe. Thread safety is ensured by the single-threaded + * access pattern managed by {@link ExposureWriterImpl.ExposureSerializingHandler}. + */ +public class LRUExposureCache implements ExposureCache { + + private final Map cache; + + public LRUExposureCache(final int capacity) { + cache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return super.size() > capacity; + } + }; + } + + @Override + public boolean add(final ExposureEvent event) { + final Key key = new Key(event); + final Value value = new Value(event); + final Value oldValue = cache.put(key, value); + return oldValue == null || !oldValue.equals(value); + } + + @Override + public Value get(final Key key) { + return cache.get(key); + } + + @Override + public int size() { + return cache.size(); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java new file mode 100644 index 00000000000..2ba7cfab153 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java @@ -0,0 +1,13 @@ +package com.datadog.openfeature.internal.exposure; + +import java.util.Map; + +public class Subject { + public final String id; + public final Map attributes; + + public Subject(final String id, final Map attributes) { + this.id = id; + this.attributes = attributes; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java new file mode 100644 index 00000000000..ce5ddb2ae01 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.exposure; + +public class Variant { + public final String key; + + public Variant(final String key) { + this.key = key; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java new file mode 100644 index 00000000000..f31ea9a3da1 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java @@ -0,0 +1,98 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import java.util.Collections; +import java.util.Map; + +/** + * Lightweight data record capturing a single flag evaluation for EVP flagevaluation emission. + * + *

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

Scalar fields and context attributes are captured at hook-fire time on the evaluation thread. + * No aggregation happens here. + */ +public final class FlagEvalEvent { + + /** The feature flag key. Never null. */ + public final String flagKey; + + /** + * The OpenFeature variant key selected for the evaluation. Null means the default value was + * returned (runtime default). + */ + public final String variant; + + /** The allocation key from flag metadata ("allocationKey"). May be null. */ + public final String allocationKey; + + /** The targeting key from the evaluation context. May be null. */ + public final String targetingKey; + + /** + * The evaluation error message when the evaluation failed, else null. Sourced from the + * OpenFeature evaluation details (error message, falling back to the error code). + */ + public final String errorMessage; + + /** + * Evaluation timestamp in milliseconds since epoch. Stamped at eval-entry time from flag metadata + * key __dd_eval_timestamp_ms, or falls back to hook-fire time when absent. This ensures + * first/last_evaluation reflect evaluation time, not hook-fire time. + */ + public final long evalTimeMs; + + /** + * Flattened evaluation context attributes. Used for the full-tier canonical context key. May be + * empty but never null. + */ + public final Map attrs; + + /** + * PII consent from the ServerConfiguration used by the evaluation. When false (privacy-preserving + * default), the targeting key is hashed and the per-evaluation context is omitted on emission. + */ + public final boolean observeFullEvaluationData; + + /** Convenience constructor; consent defaults to the privacy-preserving false. */ + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + this(flagKey, variant, allocationKey, targetingKey, null, evalTimeMs, false, attrs); + } + + /** Convenience constructor; consent defaults to the privacy-preserving false. */ + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final Map attrs) { + this(flagKey, variant, allocationKey, targetingKey, errorMessage, evalTimeMs, false, attrs); + } + + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.targetingKey = targetingKey; + this.errorMessage = errorMessage; + this.evalTimeMs = evalTimeMs; + this.observeFullEvaluationData = observeFullEvaluationData; + this.attrs = attrs != null ? attrs : Collections.emptyMap(); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java new file mode 100644 index 00000000000..ed35250b7b9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java @@ -0,0 +1,463 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +final class FlagEvaluationAggregator { + 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; + static final int EXPECTED_FULL_BUCKETS_PER_FLAG = 50; + static final int EXPECTED_USERS_PER_FLAG = 1_000; + static final int PER_FLAG_HEADROOM_MULTIPLIER = 10; + static final int EXPECTED_DEGRADED_BUCKETS_PER_FLAG = 10; + + // Derived sizing — show the math behind the bucket caps below + static final int FULL_BUCKET_SIZING_BASIS = + EXPECTED_FLAG_COUNT * EXPECTED_FULL_BUCKETS_PER_FLAG; // 125_000 + static final int PER_FLAG_BUCKET_SIZING_BASIS = + PER_FLAG_HEADROOM_MULTIPLIER * EXPECTED_USERS_PER_FLAG; // 10_000 + static final int DEGRADED_BUCKET_SIZING_BASIS = + EXPECTED_FLAG_COUNT * EXPECTED_DEGRADED_BUCKETS_PER_FLAG; // 25_000 + + // Enforced bucket caps + static final int GLOBAL_CAP = 131_072; // nearest power of two above FULL_BUCKET_SIZING_BASIS + static final int PER_FLAG_CAP = PER_FLAG_BUCKET_SIZING_BASIS; + static final int DEGRADED_CAP = 32_768; // nearest power of two above DEGRADED_BUCKET_SIZING_BASIS + + private static final byte CTX_TAG_STRING = 's'; + private static final byte CTX_TAG_BOOL = 'b'; + private static final byte CTX_TAG_INT = 'i'; + private static final byte CTX_TAG_LONG = 'l'; + private static final byte CTX_TAG_FLOAT = 'f'; + private static final byte CTX_TAG_DOUBLE = 'd'; + private static final byte CTX_TAG_OTHER = 'o'; + + final Map fullTier = new HashMap<>(); + final Map degradedTier = new HashMap<>(); + final Map perFlagCount = new HashMap<>(); + final AtomicLong droppedDegradedOverflow = new AtomicLong(0); + final AtomicInteger globalFullCount = new AtomicInteger(0); + + void aggregate(final FlagEvalEvent event) { + final boolean isDefault = event.variant == null; + final boolean observeFullEvaluationData = event.observeFullEvaluationData; + // On the protected path the context is dropped on emit, so it must not fragment buckets or be + // stored — otherwise a high-cardinality field (request_id, timestamp) blows out PER_FLAG_CAP + // and spills every subsequent evaluation into the degraded tier. + final Map prunedAttrs = observeFullEvaluationData ? event.attrs : null; + final String ctxKey = observeFullEvaluationData ? canonicalContextKey(prunedAttrs) : ""; + final FullKey fullKey = buildFullKey(event, ctxKey); + + EvalBucket bucket = fullTier.get(fullKey); + if (bucket != null) { + bucket.merge(event.evalTimeMs, isDefault); + bucket.observeFullEvaluationData &= observeFullEvaluationData; + return; + } + + final int flagCount = perFlagCount.getOrDefault(event.flagKey, 0); + if (globalFullCount.get() < GLOBAL_CAP && flagCount < PER_FLAG_CAP) { + fullTier.put( + fullKey, + new EvalBucket( + event.flagKey, + event.variant, + event.allocationKey, + event.targetingKey, + event.errorMessage, + event.evalTimeMs, + isDefault, + prunedAttrs, + observeFullEvaluationData)); + globalFullCount.incrementAndGet(); + perFlagCount.put(event.flagKey, flagCount + 1); + return; + } + + final DegradedKey degradedKey = buildDegradedKey(event); + bucket = degradedTier.get(degradedKey); + if (bucket != null) { + bucket.merge(event.evalTimeMs, isDefault); + bucket.observeFullEvaluationData &= observeFullEvaluationData; + return; + } + + if (degradedTier.size() < DEGRADED_CAP) { + degradedTier.put( + degradedKey, + new EvalBucket( + event.flagKey, + event.variant, + event.allocationKey, + null, + event.errorMessage, + event.evalTimeMs, + isDefault, + null, + observeFullEvaluationData)); + return; + } + + droppedDegradedOverflow.incrementAndGet(); + } + + boolean isEmpty() { + return fullTier.isEmpty() && degradedTier.isEmpty(); + } + + int fullTierSize() { + return fullTier.size(); + } + + long degradedEvaluationCount() { + long count = 0; + for (final EvalBucket bucket : degradedTier.values()) { + count += bucket.count; + } + return count; + } + + int bucketCount() { + return fullTier.size() + degradedTier.size(); + } + + Iterable fullBuckets() { + return fullTier.values(); + } + + Iterable degradedBuckets() { + return degradedTier.values(); + } + + void clear() { + fullTier.clear(); + degradedTier.clear(); + perFlagCount.clear(); + globalFullCount.set(0); + } + + AggregatedState snapshot() { + return new AggregatedState( + new HashMap<>(fullTier), new HashMap<>(degradedTier), droppedDegradedOverflow.get()); + } + + void simulateFullTierAtCap() { + for (int i = globalFullCount.get(); i < GLOBAL_CAP; i++) { + final String key = "synthetic-full-" + i; + fullTier.put( + new FullKey(key, "on", "alloc", false, null, null, "", false), + new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + globalFullCount.incrementAndGet(); + perFlagCount.merge(key, 1, Integer::sum); + } + } + + void simulateDegradedTierAtCap() { + for (int i = degradedTier.size(); i < DEGRADED_CAP; i++) { + final String key = "synthetic-dg-" + i; + degradedTier.put( + new DegradedKey(key, "on", "alloc", false, null), + new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + } + } + + void addDegradedBucketForTest( + final String flagKey, + final String variant, + final String allocationKey, + final String errorMessage, + final long evalTimeMs) { + degradedTier.put( + new DegradedKey(flagKey, variant, allocationKey, variant == null, errorMessage), + new EvalBucket( + flagKey, + variant, + allocationKey, + null, + errorMessage, + evalTimeMs, + variant == null, + null, + false)); + } + + private static FullKey buildFullKey(final FlagEvalEvent event, final String ctxKey) { + return new FullKey( + event.flagKey, + event.variant, + event.allocationKey, + event.variant == null, + event.errorMessage, + event.targetingKey, + ctxKey, + event.observeFullEvaluationData); + } + + private static DegradedKey buildDegradedKey(final FlagEvalEvent event) { + return new DegradedKey( + event.flagKey, + event.variant, + event.allocationKey, + event.variant == null, + event.errorMessage); + } + + static String canonicalContextKey(final Map prunedAttrs) { + if (prunedAttrs == null || prunedAttrs.isEmpty()) { + return ""; + } + final Map sorted = + (prunedAttrs instanceof TreeMap) ? prunedAttrs : new TreeMap<>(prunedAttrs); + final StringBuilder sb = new StringBuilder(); + for (final Map.Entry entry : sorted.entrySet()) { + appendLengthDelimited(sb, entry.getKey()); + appendContextValue(sb, entry.getValue()); + } + return sb.toString(); + } + + private static final String HEX_ZEROS = "00000000"; + + private static void appendLengthDelimited(final StringBuilder sb, final String s) { + // 8-char zero-padded hex length prefix, allocation-lean (no String.format on the hot path). + final String hexLength = Integer.toHexString(s.length()); + sb.append(HEX_ZEROS, 0, 8 - hexLength.length()); + sb.append(hexLength); + sb.append(s); + } + + private static void appendContextValue(final StringBuilder sb, final Object v) { + if (v instanceof Boolean) { + sb.append((char) CTX_TAG_BOOL); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Long) { + sb.append((char) CTX_TAG_LONG); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Integer) { + sb.append((char) CTX_TAG_INT); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Float) { + sb.append((char) CTX_TAG_FLOAT); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Double) { + sb.append((char) CTX_TAG_DOUBLE); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof String) { + sb.append((char) CTX_TAG_STRING); + appendLengthDelimited(sb, (String) v); + } else { + sb.append((char) CTX_TAG_OTHER); + appendLengthDelimited(sb, v == null ? "" : v.toString()); + } + } + + static class EvalBucket { + long count; + long firstEvalMs; + long lastEvalMs; + boolean runtimeDefaultUsed; + String flagKey; + String variant; + String allocationKey; + String targetingKey; + String errorMessage; + Map prunedAttrs; + // Consent to emit raw PII. For full-tier buckets this is uniform (consent is a FullKey + // dimension) and the AND-fold on merge is defensive. For degraded-tier buckets consent is NOT + // a key dimension — mixed-consent events merge here — so the AND-fold produces false whenever + // any consent-off event lands in the bucket. That's benign because the degraded wire path + // drops the targeting key and context regardless of consent, so this field has no downstream + // effect for degraded rows. + boolean observeFullEvaluationData; + + EvalBucket( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final boolean runtimeDefaultUsed, + final Map prunedAttrs, + final boolean observeFullEvaluationData) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.targetingKey = targetingKey; + this.errorMessage = errorMessage; + this.firstEvalMs = evalTimeMs; + this.lastEvalMs = evalTimeMs; + this.count = 1; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.prunedAttrs = prunedAttrs; + this.observeFullEvaluationData = observeFullEvaluationData; + } + + int prunedContextFieldCount() { + return prunedAttrs == null ? 0 : prunedAttrs.size(); + } + + void merge(final long evalTimeMs, final boolean isDefault) { + count++; + if (evalTimeMs < firstEvalMs) { + firstEvalMs = evalTimeMs; + } + if (evalTimeMs > lastEvalMs) { + lastEvalMs = evalTimeMs; + } + if (isDefault) { + runtimeDefaultUsed = true; + } + } + } + + static final class FullKey { + private final String flagKey; + private final String variant; + private final String allocationKey; + private final boolean runtimeDefaultUsed; + private final String errorMessage; + private final String targetingKey; + private final String contextKey; + // Part of the key so consent-on and consent-off evaluations never share a bucket. The + // serializer branches on this to hash the targeting key and drop the context, so events with + // different consent produce different wire rows and belong in different buckets. + private final boolean observeFullEvaluationData; + + FullKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final String targetingKey, + final String contextKey, + final boolean observeFullEvaluationData) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.errorMessage = errorMessage; + this.targetingKey = targetingKey; + this.contextKey = contextKey; + this.observeFullEvaluationData = observeFullEvaluationData; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FullKey)) { + return false; + } + final FullKey fullKey = (FullKey) o; + return runtimeDefaultUsed == fullKey.runtimeDefaultUsed + && observeFullEvaluationData == fullKey.observeFullEvaluationData + && Objects.equals(flagKey, fullKey.flagKey) + && Objects.equals(variant, fullKey.variant) + && Objects.equals(allocationKey, fullKey.allocationKey) + && Objects.equals(errorMessage, fullKey.errorMessage) + && Objects.equals(targetingKey, fullKey.targetingKey) + && Objects.equals(contextKey, fullKey.contextKey); + } + + @Override + public int hashCode() { + // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs + // on this hot bucket-lookup path. + int result = hash(flagKey, variant, allocationKey); + result = addToHash(result, runtimeDefaultUsed); + result = addToHash(result, errorMessage); + result = addToHash(result, targetingKey); + result = addToHash(result, contextKey); + return addToHash(result, observeFullEvaluationData); + } + } + + static final class DegradedKey { + // Unlike FullKey, consent is NOT a bucket dimension here: the wire serializer for degraded rows + // (FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket with isFullTier=false) drops the + // targeting key and context unconditionally, so two degraded buckets differing only in consent + // would emit byte-identical JSON with evaluation_count split — halving effective DEGRADED_CAP + // for zero wire fidelity. Mixed-consent events merge into one bucket; the AND-fold on + // EvalBucket.observeFullEvaluationData still runs but has no downstream effect for degraded + // rows. + private final String flagKey; + private final String variant; + private final String allocationKey; + private final boolean runtimeDefaultUsed; + private final String errorMessage; + + DegradedKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.errorMessage = errorMessage; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DegradedKey)) { + return false; + } + final DegradedKey that = (DegradedKey) o; + return runtimeDefaultUsed == that.runtimeDefaultUsed + && Objects.equals(flagKey, that.flagKey) + && Objects.equals(variant, that.variant) + && Objects.equals(allocationKey, that.allocationKey) + && Objects.equals(errorMessage, that.errorMessage); + } + + @Override + public int hashCode() { + // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs + // on this hot bucket-lookup path. + int result = hash(flagKey, variant, allocationKey); + result = addToHash(result, runtimeDefaultUsed); + return addToHash(result, errorMessage); + } + } + + static class AggregatedState { + final Map fullTier; + final Map degradedTier; + final long droppedDegradedOverflow; + + AggregatedState( + final Map fullTier, + final Map degradedTier, + final long droppedDegradedOverflow) { + this.fullTier = fullTier; + this.degradedTier = degradedTier; + this.droppedDegradedOverflow = droppedDegradedOverflow; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java new file mode 100644 index 00000000000..90ced6c3ac4 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java @@ -0,0 +1,338 @@ +package com.datadog.openfeature.internal.flagevaluation; + +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.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 = "]}".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 #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_"; + + // 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 EncodedPayloads buildPayloads( + final List events, + final Map context, + final int payloadSizeLimitBytes) { + final byte[] prefix = payloadPrefix(context); + EncodedPayloadBuilder current = new EncodedPayloadBuilder(prefix); + final List payloads = new ArrayList<>(); + long droppedPayloadLimit = 0; + long degradedPayloadLimit = 0; + + for (final FlagEvaluationEvent event : events) { + byte[] eventBytes = encodeEvent(event); + + if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { + payloads.add(current.toByteArray()); + current = new EncodedPayloadBuilder(prefix); + } + + if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { + current.add(eventBytes); + continue; + } + + final FlagEvaluationEvent degraded = event.withoutTargetingKeyAndContext(); + if (degraded != null) { + eventBytes = encodeEvent(degraded); + if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { + payloads.add(current.toByteArray()); + current = new EncodedPayloadBuilder(prefix); + } + if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { + current.add(eventBytes); + degradedPayloadLimit += event.evaluation_count; + continue; + } + } + + droppedPayloadLimit += event.evaluation_count; + } + + if (!current.isEmpty()) { + payloads.add(current.toByteArray()); + } + return new EncodedPayloads(payloads, droppedPayloadLimit, degradedPayloadLimit); + } + + private static byte[] payloadPrefix(final Map context) { + final byte[] contextJson = JsonWriting.write(generator -> writeValue(generator, context)); + return ("{\"context\":" + new String(contextJson, UTF_8) + ",\"flagEvaluations\":[") + .getBytes(UTF_8); + } + + 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 { + final List bodies; + final long droppedPayloadLimit; + final long degradedPayloadLimit; + + private EncodedPayloads( + final List bodies, + final long droppedPayloadLimit, + final long degradedPayloadLimit) { + this.bodies = bodies; + this.droppedPayloadLimit = droppedPayloadLimit; + this.degradedPayloadLimit = degradedPayloadLimit; + } + } + + private static final class EncodedPayloadBuilder { + private final byte[] prefix; + private final List events = new ArrayList<>(); + private int eventBytes; + + private EncodedPayloadBuilder(final byte[] prefix) { + this.prefix = prefix; + } + + private boolean isEmpty() { + return events.isEmpty(); + } + + private boolean canAdd(final byte[] event, final int payloadSizeLimitBytes) { + return sizeWith(event) <= payloadSizeLimitBytes; + } + + private int sizeWith(final byte[] event) { + return prefix.length + PAYLOAD_SUFFIX.length + eventBytes + event.length + events.size(); + } + + private void add(final byte[] event) { + events.add(event); + eventBytes += event.length; + } + + private byte[] toByteArray() { + final int size = prefix.length + PAYLOAD_SUFFIX.length + eventBytes; + final ByteArrayOutputStream out = new ByteArrayOutputStream(size + events.size()); + out.write(prefix, 0, prefix.length); + for (int i = 0; i < events.size(); i++) { + if (i > 0) { + out.write(JSON_COMMA, 0, JSON_COMMA.length); + } + final byte[] event = events.get(i); + out.write(event, 0, event.length); + } + out.write(PAYLOAD_SUFFIX, 0, PAYLOAD_SUFFIX.length); + return out.toByteArray(); + } + } + + static class FlagEvaluationEvent { + public final long timestamp; + public final FlagKeyObject flag; + public final long first_evaluation; + public final long last_evaluation; + public final long evaluation_count; + public final KeyObject variant; + public final KeyObject allocation; + public final String targeting_key; + public final Boolean runtime_default_used; + public final EventContext context; + public final ErrorObject error; + + FlagEvaluationEvent( + final long timestamp, + final String flagKey, + final long firstEvalMs, + final long lastEvalMs, + final long count, + final String variant, + final String allocation, + final String targetingKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final Map evaluationAttrs) { + this.timestamp = timestamp; + this.flag = new FlagKeyObject(flagKey); + this.first_evaluation = firstEvalMs; + this.last_evaluation = lastEvalMs; + this.evaluation_count = count; + this.variant = (variant != null && !variant.isEmpty()) ? new KeyObject(variant) : null; + this.allocation = + (allocation != null && !allocation.isEmpty()) ? new KeyObject(allocation) : null; + this.targeting_key = targetingKey; + this.runtime_default_used = runtimeDefaultUsed ? Boolean.TRUE : null; + this.context = + (evaluationAttrs != null && !evaluationAttrs.isEmpty()) + ? new EventContext(evaluationAttrs) + : null; + this.error = + (errorMessage != null && !errorMessage.isEmpty()) ? new ErrorObject(errorMessage) : null; + } + + static FlagEvaluationEvent fromBucket( + final FlagEvaluationAggregator.EvalBucket bucket, + final boolean isFullTier, + final boolean observeFullEvaluationData, + final long flushTimeMs) { + final boolean includeRawContext = isFullTier && observeFullEvaluationData; + return new FlagEvaluationEvent( + flushTimeMs, + bucket.flagKey, + bucket.firstEvalMs, + bucket.lastEvalMs, + bucket.count, + bucket.variant, + bucket.allocationKey, + resolveTargetingKey(bucket.targetingKey, isFullTier, observeFullEvaluationData), + bucket.runtimeDefaultUsed, + bucket.errorMessage, + includeRawContext ? bucket.prunedAttrs : null); + } + + private static String resolveTargetingKey( + final String rawTargetingKey, + final boolean isFullTier, + final boolean observeFullEvaluationData) { + if (!isFullTier || rawTargetingKey == null) { + return null; + } + if (observeFullEvaluationData) { + return rawTargetingKey; + } + return HASHED_TARGETING_KEY_PREFIX + hashTargetingKey(rawTargetingKey); + } + + FlagEvaluationEvent withoutTargetingKeyAndContext() { + if (targeting_key == null && context == null) { + return null; + } + return new FlagEvaluationEvent( + timestamp, + flag.key, + first_evaluation, + last_evaluation, + evaluation_count, + keyOf(variant), + keyOf(allocation), + null, + Boolean.TRUE.equals(runtime_default_used), + messageOf(error), + null); + } + } + + private static String keyOf(final KeyObject object) { + return object == null ? null : object.key; + } + + private static String messageOf(final ErrorObject object) { + return object == null ? null : object.message; + } + + static class KeyObject { + public final String key; + + KeyObject(final String key) { + this.key = key; + } + } + + static class FlagKeyObject { + public final String key; + + FlagKeyObject(final String key) { + this.key = key; + } + } + + static class ErrorObject { + public final String message; + + ErrorObject(final String message) { + this.message = message; + } + } + + static class EventContext { + public final Map evaluation; + + EventContext(final Map evaluation) { + this.evaluation = evaluation; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java new file mode 100644 index 00000000000..8888f6331ba --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java @@ -0,0 +1,543 @@ +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; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * EVP flagevaluation writer for Java. + * + *

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, + * allocationKey, runtimeDefault, errorMessage) - no targetingKey/context. Canonical context key: + * sorted entries, type-tagged length-delimited encoding - NOT a hash (collision-safe, comparable + * string identity). Context pruning: deterministic (sort before cut), <=256 fields, string values + * <=256 chars; the pruned attributes are what gets aggregated and serialized. Caps: + * globalCap=131072, perFlagCap=10000, degradedCap=32768. Eval-time: min/max of + * firstEvalMs/lastEvalMs across events in the same bucket. Runtime default: absent variant means + * runtimeDefaultUsed=true. Flush interval: 10 seconds. Queue: bounded 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 FlagEvaluationPipeline implements FlagEvaluationWriter, AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationPipeline.class); + + 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 = 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"; + static final String FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC = "flagevaluation.context.truncated"; + static final String DROP_REASON_QUEUE_OVERFLOW = "queue_overflow"; + static final String DROP_REASON_CLOSED = "closed"; + static final String DROP_REASON_DEGRADED_CAP = "degraded_cap"; + static final String DROP_REASON_PAYLOAD_LIMIT = "payload_limit"; + static final String DEGRADED_REASON_CARDINALITY_CAP = "cardinality_cap"; + static final String DEGRADED_REASON_PAYLOAD_LIMIT = "payload_limit"; + private static final String FLAG_EVALUATION_ROUTE = "flagevaluation"; + private final RuntimeServices services; + + 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 void countMetric(final String metricName, final long value, final String reason) { + services.countMetric(metricName, value, reason); + } + + /** + * Observable counter for events dropped because the bounded hand-off queue was full when the hook + * tried to enqueue (backpressure). Incremented on the hook thread, surfaced on flush. + */ + private final AtomicLong droppedQueueOverflow = new AtomicLong(0); + + /** + * Per-reason-tag counters for evaluations whose context was truncated by copyPrunedContext. Keyed + * by the sorted comma-separated reason string (e.g. "max_key_length,max_value_length"). + * Incremented on the hook thread, drained and emitted on flush. + */ + private final ConcurrentHashMap contextTruncatedCounts = + new ConcurrentHashMap<>(); + + public FlagEvaluationPipeline( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this.services = services; + this.queue = new EventQueue<>(capacity); + this.serializer = + new FlagEvaluationSerializingHandler( + transport, + queue, + flushInterval, + timeUnit, + context, + droppedQueueOverflow, + contextTruncatedCounts, + FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, + services); + this.serializerThread = services.newThread("evaluations", serializer); + } + + /** Starts the background serializing thread. */ + public void start() { + synchronized (lifecycleLock) { + if (closed.get()) { + return; + } + this.serializerThread.start(); + } + } + + /** Test seam: current full-tier bucket count in the worker's aggregator. */ + int aggregatorFullTierSizeForTest() { + return serializer.aggregator.fullTierSize(); + } + + @Override + public void close() { + final boolean workerRunning; + synchronized (lifecycleLock) { + if (!closed.compareAndSet(false, true)) { + return; + } + workerRunning = this.serializerThread.isAlive(); + if (workerRunning) { + // Ask the worker to drain the queue and final-flush, then interrupt to wake it from poll(). + serializer.requestShutdown(); + this.serializerThread.interrupt(); + } + } + if (Thread.currentThread() == this.serializerThread) { + return; + } + if (workerRunning) { + try { + // Bounded wait for the worker's final flush so queued events are not lost on shutdown. + this.serializerThread.join(TimeUnit.SECONDS.toMillis(5)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + // enqueue() is lock-free, so a producer that passed the closed check before close() ran can + // still land an event after the worker's final drain. Sweep the remainder so that loss is + // counted rather than silently stranded in a queue nobody polls again. Only safe once the + // worker is gone: the queue is single-consumer, so sweeping alongside a live worker would + // break that contract. If join() timed out the worker is still draining, so skip the sweep. + if (!this.serializerThread.isAlive()) { + sweepAndCountResidualEvents(); + } + } + + /** + * Counts events left in the queue after the worker has exited. Must only be called when the + * serializer thread is not alive - the queue permits a single consumer. + */ + private void sweepAndCountResidualEvents() { + long residual = 0; + while (queue.poll() != null) { + residual++; + } + countMetric(FLAG_EVALUATION_DROPPED_METRIC, residual, DROP_REASON_CLOSED); + } + + @Override + public void enqueue(final FlagEvalEvent event) { + if (event == null) { + return; + } + if (closed.get()) { + countClosedDrop(); + return; + } + // Deliberately lock-free: the hand-off queue is MPSC by design, so serializing producers on a + // monitor here would negate that and turn every evaluation in every application thread into + // contention on one lock. A producer that passed the check above can still offer after close() + // has started; that residue is accounted for by the worker's bounded post-drain passes and by + // close()'s post-join sweep, so shutdown loss stays observable. + // + // Safe publication of the event (including the context snapshot built by the hook) comes from + // the queue's own offer/poll ordering, not from any monitor held here. + // + // Non-blocking offer. Count overflow so loss is observable rather than silent; the count is + // surfaced on the next flush. The hook's pre-queue guard (see FlagEvalLoggingHook) samples the + // queue depth before doing any context-copy work, so a saturated queue costs an + // AtomicInteger.get(); the offer here still races with the worker and can legitimately fail. + if (!queue.offer(event)) { + droppedQueueOverflow.incrementAndGet(); + } + } + + @Override + public boolean hasCapacityForEnqueue() { + return queue.size() < queue.capacity(); + } + + @Override + public void countPreQueueOverflow() { + droppedQueueOverflow.incrementAndGet(); + } + + @Override + public void countContextTruncated(final String reason) { + contextTruncatedCounts.computeIfAbsent(reason, k -> new AtomicLong(0)).incrementAndGet(); + } + + private void countClosedDrop() { + countMetric(FLAG_EVALUATION_DROPPED_METRIC, 1, DROP_REASON_CLOSED); + } + + /** Returns the count of events dropped due to queue-overflow backpressure (observable). */ + long droppedQueueOverflow() { + return droppedQueueOverflow.get(); + } + + /** Test seam: returns one queued event without starting the worker. */ + FlagEvalEvent pollQueuedEventForTest() { + return queue.poll(); + } + + /** Test seam: flushes serializer state without starting the worker. */ + void flushForTest() { + serializer.flush(); + } + + // ---- Serializing handler (background thread logic) ---- + + static class FlagEvaluationSerializingHandler implements Runnable { + private final 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( + value = "AT_NONATOMIC_64BIT_PRIMITIVE", + justification = "the field is confined to the single serializer thread") + private long lastTicks; + + private final EventTransport transport; + final Map context; + private final AtomicLong droppedQueueOverflow; + private final ConcurrentHashMap contextTruncatedCounts; + private final int payloadSizeLimitBytes; + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + // Shutdown coordination: set by close(), drives a final drain+flush before the worker exits. + private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); + private final CountDownLatch finalFlushDone = new CountDownLatch(1); + + FlagEvaluationSerializingHandler( + final EventTransport transport, + final EventQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context, + final AtomicLong droppedQueueOverflow, + final ConcurrentHashMap contextTruncatedCounts, + final int payloadSizeLimitBytes, + final RuntimeServices services) { + this.services = services; + this.queue = queue; + this.transport = transport; + this.context = context; + this.droppedQueueOverflow = droppedQueueOverflow; + this.contextTruncatedCounts = contextTruncatedCounts; + this.payloadSizeLimitBytes = payloadSizeLimitBytes; + this.lastTicks = System.nanoTime(); + this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); + LOGGER.debug("starting flag evaluation serializer"); + } + + /** Signals the worker to drain the queue and perform a final flush before exiting. */ + void requestShutdown() { + shutdownRequested.set(true); + } + + @Override + public void run() { + try { + runDutyCycle(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + // On exit (interrupt or shutdown request), drain everything still buffered and flush it so + // queued events are not lost on shutdown. + // + // close() interrupts this thread to break it out of poll(). The final flush does socket + // 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 + // rows already left the queue. + final boolean wasInterrupted = Thread.interrupted(); + try { + drainAndFlush(); + } finally { + finalFlushDone.countDown(); + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + } + } + LOGGER.debug("flag evaluation processor worker exited."); + } + + private void runDutyCycle() throws InterruptedException { + final Thread thread = Thread.currentThread(); + while (!thread.isInterrupted() && !shutdownRequested.get()) { + final FlagEvalEvent event = queue.poll(100, TimeUnit.MILLISECONDS); + if (event != null) { + aggregateEvent(event); + } + flushIfNecessary(); + } + } + + void drainAndFlush() { + FlagEvalEvent event; + while ((event = queue.poll()) != null) { + aggregateEvent(event); + } + flush(); + } + + // ---- Aggregation logic ---- + + /** Routes an event into the full tier or degraded tier, or drops and counts on overflow. */ + void aggregateEvent(final FlagEvalEvent event) { + try { + aggregator.aggregate(event); + } catch (LinkageError | RuntimeException e) { + LOGGER.debug("Could not aggregate flag evaluation event", e); + } + } + + // ---- Flush logic ---- + + void flushIfNecessary() { + if (shouldFlush()) { + flush(); + } + } + + void flush() { + // Surface backpressure (queue-overflow) drops as an observable warning even when there is + // nothing else to flush. + final long qDrops = droppedQueueOverflow.getAndSet(0); + countMetric(FLAG_EVALUATION_DROPPED_METRIC, qDrops, DROP_REASON_QUEUE_OVERFLOW); + if (qDrops > 0) { + LOGGER.warn( + "flag evaluation queue full - dropped {} evaluation(s) under backpressure" + + " (best-effort telemetry)", + qDrops); + } + final long dgDrops = aggregator.droppedDegradedOverflow.getAndSet(0); + countMetric(FLAG_EVALUATION_DROPPED_METRIC, dgDrops, DROP_REASON_DEGRADED_CAP); + if (dgDrops > 0) { + LOGGER.warn( + "degraded aggregation tier full - dropped {} evaluation(s); raise degraded cap" + + " (best-effort telemetry)", + dgDrops); + } + + // Drain per-reason context-truncation counters and emit one metric per unique reason tag. + for (final Map.Entry entry : contextTruncatedCounts.entrySet()) { + final long count = entry.getValue().getAndSet(0); + if (count > 0) { + countMetric(FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, count, entry.getKey()); + } + } + + if (aggregator.isEmpty()) { + return; + } + try { + countMetric( + FLAG_EVALUATION_DEGRADED_METRIC, + aggregator.degradedEvaluationCount(), + DEGRADED_REASON_CARDINALITY_CAP); + final List events = buildEventList(); + if (events.isEmpty()) { + return; + } + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(events, context, payloadSizeLimitBytes); + countMetric( + FLAG_EVALUATION_DROPPED_METRIC, + payloads.droppedPayloadLimit, + DROP_REASON_PAYLOAD_LIMIT); + countMetric( + FLAG_EVALUATION_DEGRADED_METRIC, + payloads.degradedPayloadLimit, + DEGRADED_REASON_PAYLOAD_LIMIT); + if (payloads.bodies.size() > 1) { + countMetric(FLAG_EVALUATION_SPLITS_METRIC, payloads.bodies.size() - 1, null); + } + if (payloads.droppedPayloadLimit > 0) { + LOGGER.warn( + "flag evaluation payload too large - dropped {} evaluation(s)" + + " (best-effort telemetry)", + payloads.droppedPayloadLimit); + } + for (final byte[] payload : payloads.bodies) { + transport.post(FLAG_EVALUATION_ROUTE, payload); + } + } catch (Exception e) { + LOGGER.error("Could not submit flag evaluations", e); + } finally { + // Best-effort: always clear the aggregator after a flush attempt. Retaining buckets across + // flushes on encode failure would let one unserializable value (for example a NaN Double a + // customer put in the context) permanently block every subsequent flush. + aggregator.clear(); + lastTicks = System.nanoTime(); + } + } + + private List buildEventList() { + final long flushTimeMs = System.currentTimeMillis(); + // Consent is read per bucket from the value each event snapshotted at evaluation time, not + // from the gateway here: CURRENT_CONFIG may have been overwritten by a later RC update since + // these evaluations happened, and reading it at flush would apply the wrong config's consent. + final List events = + new ArrayList<>(aggregator.bucketCount()); + for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullBuckets()) { + events.add( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, bucket.observeFullEvaluationData, flushTimeMs)); + } + for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedBuckets()) { + events.add( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, false, bucket.observeFullEvaluationData, flushTimeMs)); + } + return events; + } + + private boolean shouldFlush() { + if (aggregator.isEmpty() && droppedQueueOverflow.get() == 0) { + return false; + } + final long nanoTime = System.nanoTime(); + final long ticks = nanoTime - lastTicks; + if (ticks > ticksRequiredToFlush) { + lastTicks = nanoTime; + return true; + } + return false; + } + } + + // ---- Test-seam inner class (package-private) ---- + + /** + * Test-accessible handler that exposes {@link #drainAndAggregate()} and {@link #flush()} without + * starting a real background thread. + */ + static class SerializingHandlerForTest extends FlagEvaluationSerializingHandler { + + SerializingHandlerForTest( + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this(transport, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, services); + } + + SerializingHandlerForTest( + final EventTransport transport, + final Map context, + final int payloadSizeLimitBytes, + final RuntimeServices services) { + super( + transport, + new EventQueue<>(DEFAULT_CAPACITY), + Long.MAX_VALUE, // effectively never auto-flush + TimeUnit.NANOSECONDS, + context, + new AtomicLong(0), + new ConcurrentHashMap<>(), + payloadSizeLimitBytes, + services); + } + + private final List staged = new ArrayList<>(); + + /** Adds an event to the staged list (simulates hook enqueue). */ + void add(final FlagEvalEvent event) { + staged.add(event); + } + + /** Aggregates all staged events and returns the current aggregation state. */ + FlagEvaluationAggregator.AggregatedState drainAndAggregate() { + for (final FlagEvalEvent e : staged) { + aggregateEvent(e); + } + staged.clear(); + return aggregator.snapshot(); + } + + /** Simulates filling the full tier to GLOBAL_CAP by injecting synthetic distinct buckets. */ + void simulateFullTierAtCap() { + aggregator.simulateFullTierAtCap(); + } + + /** + * Simulates filling the degraded tier to DEGRADED_CAP by injecting synthetic distinct buckets. + */ + void simulateDegradedTierAtCap() { + aggregator.simulateDegradedTierAtCap(); + } + + void addDroppedDegradedOverflowForTest(final long count) { + aggregator.droppedDegradedOverflow.addAndGet(count); + } + + void addDegradedBucketForTest( + final String flagKey, + final String variant, + final String allocationKey, + final String errorMessage, + final long evalTimeMs) { + aggregator.addDegradedBucketForTest( + flagKey, variant, allocationKey, errorMessage, evalTimeMs); + } + + void clearAggregationForTest() { + aggregator.clear(); + } + + int fullTierSizeForTest() { + return aggregator.fullTierSize(); + } + } +} 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/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/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java new file mode 100644 index 00000000000..f48c3e32e0a --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java @@ -0,0 +1,308 @@ +package com.datadog.openfeature.internal.exposure; + +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 java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class LRUExposureCacheTest { + + @Test + void testAddingElements() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + boolean added = cache.add(event); + + assertTrue(added); + assertEquals(1, cache.size()); + } + + @Test + void testAddingDuplicateEventsReturnsFalse() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + cache.add(event); + boolean duplicateAdded = cache.add(event); + + assertFalse(duplicateAdded); + assertEquals(1, cache.size()); + } + + @Test + void testAddingEventsWithSameKeyButDifferentDetailsUpdatesCache() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event1 = createEvent("flag", "subject", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag", "subject", "variant2", "allocation2"); + ExposureCache.Key key = new ExposureCache.Key(event1); + + boolean added1 = cache.add(event1); + boolean added2 = cache.add(event2); + ExposureCache.Value retrieved = cache.get(key); + + assertTrue(added1); + assertTrue(added2); + assertEquals(1, cache.size()); + assertEquals("variant2", retrieved.variant); + assertEquals("allocation2", retrieved.allocation); + } + + @Test + void testSerialIdAppearingEmitsANewExposure() { + assertEquals(2, emissions(null, 7)); + } + + @Test + void testSerialIdDisappearingEmitsANewExposure() { + assertEquals(2, emissions(7, null)); + } + + @Test + void testSerialIdChangingEmitsANewExposure() { + assertEquals(2, emissions(7, 8)); + } + + @Test + void testSerialIdCycleEmitsAnExposurePerTransition() { + assertEquals(3, emissions(7, 8, 7)); + } + + @Test + void testUnchangedSerialIdIsDeduplicated() { + assertEquals(1, emissions(7, 7)); + } + + @Test + void testUnchangedAbsentSerialIdIsDeduplicated() { + assertEquals(1, emissions(null, null)); + } + + @Test + void testSerialIdZeroIsDistinguishedFromAbsent() { + assertEquals(2, emissions(null, 0)); + assertEquals(2, emissions(0, null)); + } + + @Test + void testSerialIdIsRetainedOnTheCachedValue() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation", 0); + + cache.add(event); + + assertEquals(Integer.valueOf(0), cache.get(new ExposureCache.Key(event)).serialId); + } + + @Test + void testLruEvictionWhenCapacityExceeded() { + LRUExposureCache cache = new LRUExposureCache(2); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key3 = new ExposureCache.Key(event3); + + cache.add(event1); + cache.add(event2); + cache.add(event3); + + assertEquals(2, cache.size()); + assertNull(cache.get(key1)); // event1 should be evicted + assertNotNull(cache.get(key3)); // event3 should be present + assertEquals("variant3", cache.get(key3).variant); + assertEquals("allocation3", cache.get(key3).allocation); + } + + @Test + void testSingleCapacityCache() { + LRUExposureCache cache = new LRUExposureCache(1); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + + cache.add(event1); + cache.add(event2); + + assertEquals(1, cache.size()); + } + + @Test + void testZeroCapacityCache() { + LRUExposureCache cache = new LRUExposureCache(0); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + boolean added = cache.add(event); + + assertTrue(added); + assertEquals(0, cache.size()); + } + + @Test + void testEmptyCacheSize() { + LRUExposureCache cache = new LRUExposureCache(5); + + assertEquals(0, cache.size()); + } + + @Test + void testMultipleAdditionsWithSameFlagDifferentSubjects() { + LRUExposureCache cache = new LRUExposureCache(10); + List events = new ArrayList<>(); + for (int index = 0; index < 5; index++) { + events.add(createEvent("flag", "subject" + index, "variant", "allocation")); + } + + for (ExposureEvent event : events) { + assertTrue(cache.add(event)); + } + + assertEquals(5, cache.size()); + } + + @Test + void testMultipleAdditionsWithSameSubjectDifferentFlags() { + LRUExposureCache cache = new LRUExposureCache(10); + List events = new ArrayList<>(); + for (int index = 0; index < 5; index++) { + events.add(createEvent("flag" + index, "subject", "variant", "allocation")); + } + + for (ExposureEvent event : events) { + assertTrue(cache.add(event)); + } + + assertEquals(5, cache.size()); + } + + @Test + void testKeyEqualityWithNullValues() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event1 = + new ExposureEvent( + System.currentTimeMillis(), + new Allocation("allocation"), + new Flag(null), + new Variant("variant"), + new Subject(null, emptyMap()), + null); + ExposureEvent event2 = + new ExposureEvent( + System.currentTimeMillis(), + new Allocation("allocation"), + new Flag(null), + new Variant("variant"), + new Subject(null, emptyMap()), + null); + + cache.add(event1); + boolean duplicateAdded = cache.add(event2); + + assertFalse(duplicateAdded); + assertEquals(1, cache.size()); + } + + @Test + void testUpdatingExistingKeyMaintainsLruPosition() { + LRUExposureCache cache = new LRUExposureCache(3); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + ExposureEvent event1Updated = createEvent("flag1", "subject1", "variant2", "allocation2"); + ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key2 = new ExposureCache.Key(event2); + ExposureCache.Key key4 = new ExposureCache.Key(event4); + + cache.add(event1); + cache.add(event2); + cache.add(event3); + cache.add(event1Updated); // Updates event1, moves to most recent + cache.add(event4); // Should evict event2, not event1 + + assertEquals(3, cache.size()); + assertNotNull(cache.get(key1)); // event1 should be updated and present + assertEquals("variant2", cache.get(key1).variant); // verify it was updated + assertEquals("allocation2", cache.get(key1).allocation); + assertNull(cache.get(key2)); // event2 should be evicted + assertNotNull(cache.get(key4)); // event4 should be present + assertEquals("variant4", cache.get(key4).variant); + } + + @Test + void testDuplicateExposureKeepsSubjectHotInLruOrder() { + LRUExposureCache cache = new LRUExposureCache(3); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + // same key + same details as event1: will go through the "duplicate" path + ExposureEvent event1Duplicate = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); + + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key2 = new ExposureCache.Key(event2); + ExposureCache.Key key4 = new ExposureCache.Key(event4); + + // Fill cache + boolean added1 = cache.add(event1); + boolean added2 = cache.add(event2); + boolean added3 = cache.add(event3); + + // Duplicate exposure for subject1: should *not* change size, but *should* bump recency + boolean duplicateAdded = cache.add(event1Duplicate); + + // Now push over capacity: the least recently used *non-hot* entry (event2) should be evicted + boolean added4 = cache.add(event4); + + assertTrue(added1); + assertTrue(added2); + assertTrue(added3); + assertFalse(duplicateAdded); // dedup correctly + assertTrue(added4); + + assertEquals(3, cache.size()); + + assertNotNull(cache.get(key1)); // hot subject1 should still be present + assertNull(cache.get(key2)); // subject2 should be evicted + assertNotNull(cache.get(key4)); // newest subject4 should be present + + assertEquals("variant1", cache.get(key1).variant); + assertEquals("allocation1", cache.get(key1).allocation); + } + + private static ExposureEvent createEvent( + String flag, String subject, String variant, String allocation) { + return createEvent(flag, subject, variant, allocation, null); + } + + private static ExposureEvent createEvent( + String flag, String subject, String variant, String allocation, Integer serialId) { + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation(allocation), + new Flag(flag), + new Variant(variant), + new Subject(subject, emptyMap()), + serialId); + } + + /** + * Number of exposures a run of same-flag, same-subject evaluations emits: the cache reports true + * only for the ones it does not suppress as duplicates. + */ + private static int emissions(final Integer... serialIds) { + LRUExposureCache cache = new LRUExposureCache(5); + int emitted = 0; + for (Integer serialId : serialIds) { + if (cache.add(createEvent("flag", "subject", "variant", "allocation", serialId))) { + emitted++; + } + } + return emitted; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java new file mode 100644 index 00000000000..811cd0eaa08 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java @@ -0,0 +1,424 @@ +package com.datadog.openfeature.internal.flagevaluation; + +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FlagEvaluationAggregatorTest { + + @Test + void identicalEventsAggregateIntoOneBucketWithCount2() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 2000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(1, state.fullTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertEquals(1000L, bucket.firstEvalMs); + assertEquals(2000L, bucket.lastEvalMs); + assertTrue(bucket.firstEvalMs <= bucket.lastEvalMs); + } + + @Test + void differentValueTypesProduceDifferentBuckets() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map attrsInt = new HashMap<>(); + attrsInt.put("score", 1); + final Map attrsStr = new HashMap<>(); + attrsStr.put("score", "1"); + + aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsInt)); + aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsStr)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(2, state.fullTier.size()); + } + + @Test + void nulCharactersInKeyFieldsDoNotCollide() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("a\0b", "c", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("a", "b\0c", "alloc1", "user-1", 1000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(2, state.fullTier.size()); + } + + @Test + void globalCapOverflowRoutesToDegradedTier() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.simulateFullTierAtCap(); + aggregator.aggregate(simpleEvent("extra-flag", "on")); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertTrue(state.degradedTier.size() > 0); + assertEquals(0, state.droppedDegradedOverflow); + } + + @Test + void degradedCapOverflowIncrementsDroppedCounter() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.simulateFullTierAtCap(); + aggregator.simulateDegradedTierAtCap(); + aggregator.aggregate(simpleEvent("drop-flag", "on")); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertTrue(state.droppedDegradedOverflow > 0); + } + + @Test + void perFlagCapOverflowRoutesToDegradedTierAndMergesSameDegradedKey() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); + + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(0, state.fullTier.size()); + assertEquals(1, state.degradedTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = + state.degradedTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertEquals(1000L, bucket.firstEvalMs); + assertEquals(2000L, bucket.lastEvalMs); + } + + @Test + void mixedConsentDegradedEvaluationsMergeIntoOneBucket() { + // Mirror of mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets, but for the degraded + // tier: the wire serializer for degraded rows drops the targeting key and context regardless + // of consent, so two events differing only in consent emit byte-identical JSON. They must + // share a bucket, otherwise DEGRADED_CAP is effectively halved for zero wire fidelity gain. + // The AND-fold on EvalBucket.observeFullEvaluationData still runs but the value has no + // downstream effect for degraded rows. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); + + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, false, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(0, state.fullTier.size()); + assertEquals(1, state.degradedTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = + state.degradedTier.values().iterator().next(); + assertEquals(2, bucket.count); + // AND-fold collapses to consent-off; benign for degraded rows but a documented invariant. + assertFalse(bucket.observeFullEvaluationData); + } + + @Test + void absentVariantSetsRuntimeDefaultUsed() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("flag-c", null, "alloc1", "user-1", 1000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(1, state.fullTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); + assertTrue(bucket.runtimeDefaultUsed); + } + + @Test + void aggregatorStoresPrunedAttrsVerbatim() { + // Hot-path hook (DDEvaluator#copyPrunedContext) now delivers an already-pruned map. + // The aggregator no longer re-prunes; it stores what it is given. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map preprunedAttrs = new HashMap<>(); + for (int i = 0; i < 100; i++) { + preprunedAttrs.put("key" + i, "v" + i); + } + + aggregator.aggregate(event("flag-d", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); + assertEquals(100, bucket.prunedContextFieldCount()); + assertEquals(100, bucket.prunedAttrs.size()); + } + + @Test + void emptyContextInputsProduceEmptyCanonicalKey() { + assertEquals("", FlagEvaluationAggregator.canonicalContextKey(null)); + assertEquals("", FlagEvaluationAggregator.canonicalContextKey(emptyMap())); + } + + @Test + void canonicalContextKeyEncodesSupportedValueTypes() { + final Map attrs = new HashMap<>(); + attrs.put("bool", true); + attrs.put("double", 1.5d); + attrs.put("float", 1.25f); + attrs.put("int", 1); + attrs.put("long", 2L); + attrs.put("null", null); + attrs.put("object", new StringBuilder("other")); + attrs.put("string", "value"); + + final String key = FlagEvaluationAggregator.canonicalContextKey(attrs); + + assertEquals(key, FlagEvaluationAggregator.canonicalContextKey(new HashMap<>(attrs))); + assertTrue(key.contains("bool")); + assertTrue(key.contains("string")); + assertTrue(key.contains("other")); + } + + @Test + void aggregatorStoresPrePrunedAttrsWithoutRePruning() { + // Value-length pruning moved to DDEvaluator#copyPrunedContext (hot path). The aggregator + // stores what it is given, so any long value present in the input remains present. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map preprunedAttrs = new HashMap<>(); + preprunedAttrs.put("short-val", "ok"); + + aggregator.aggregate(event("flag-e", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); + assertTrue(bucket.prunedAttrs.containsKey("short-val")); + } + + @Test + void capSizingUsesNamedScaleConstants() { + assertEquals(125_000, FlagEvaluationAggregator.FULL_BUCKET_SIZING_BASIS); + assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_BUCKET_SIZING_BASIS); + assertEquals(25_000, FlagEvaluationAggregator.DEGRADED_BUCKET_SIZING_BASIS); + assertEquals(131_072, FlagEvaluationAggregator.GLOBAL_CAP); + assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_CAP); + assertEquals(32_768, FlagEvaluationAggregator.DEGRADED_CAP); + } + + @Test + void flagEvalEventDoesNotCarryReason() { + final boolean hasReasonField = + Arrays.stream( + com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent.class + .getDeclaredFields()) + .anyMatch(field -> field.getName().equals("reason")); + + assertFalse(hasReasonField); + } + + @Test + void evalBucketTracksBoundsDefaultStateAndNullContextFieldCount() { + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false); + + assertEquals(0, bucket.prunedContextFieldCount()); + + bucket.merge(900L, true); + bucket.merge(1100L, false); + bucket.merge(1000L, false); + + assertEquals(4, bucket.count); + assertEquals(900L, bucket.firstEvalMs); + assertEquals(1100L, bucket.lastEvalMs); + assertTrue(bucket.runtimeDefaultUsed); + } + + @Test + void fullKeyEqualityUsesEveryDimension() { + final FlagEvaluationAggregator.FullKey base = + fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); + final FlagEvaluationAggregator.FullKey same = + fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); + + assertEquals(base, base); + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, null); + assertNotEquals(base, "not-a-key"); + assertNotEquals(base, fullKey("other", "on", "alloc", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "off", "alloc", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "other", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", true, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "other", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "other", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "user", "other")); + } + + @Test + void degradedKeyEqualityUsesEveryDimension() { + final FlagEvaluationAggregator.DegradedKey base = + degradedKey("flag", "on", "alloc", false, "error"); + final FlagEvaluationAggregator.DegradedKey same = + degradedKey("flag", "on", "alloc", false, "error"); + + assertEquals(base, base); + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, null); + assertNotEquals(base, "not-a-key"); + assertNotEquals(base, degradedKey("other", "on", "alloc", false, "error")); + assertNotEquals(base, degradedKey("flag", "off", "alloc", false, "error")); + assertNotEquals(base, degradedKey("flag", "on", "other", false, "error")); + assertNotEquals(base, degradedKey("flag", "on", "alloc", true, "error")); + assertNotEquals(base, degradedKey("flag", "on", "alloc", false, "other")); + } + + @Test + void mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets() { + // Consent is part of FullKey: two evaluations that differ only in consent produce different + // wire rows (raw vs hashed targeting key, context vs no context) and belong in different + // buckets. Merging them would silently downgrade the consent-on row to the protected shape. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, false, emptyMap())); + + assertEquals(2, aggregator.fullTierSize()); + int onCount = 0; + int offCount = 0; + for (final FlagEvaluationAggregator.EvalBucket bucket : + aggregator.snapshot().fullTier.values()) { + if (bucket.observeFullEvaluationData) { + onCount++; + } else { + offCount++; + } + } + assertEquals(1, onCount); + assertEquals(1, offCount); + } + + @Test + void sameConsentEvaluationsForSameSubjectMergeIntoOneBucket() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, true, emptyMap())); + + final FlagEvaluationAggregator.EvalBucket bucket = + aggregator.snapshot().fullTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertTrue(bucket.observeFullEvaluationData); + } + + @Test + void protectedPathCollapsesDifferingContextIntoOneBucket() { + // Same subject, different request-id contexts, consent off: the context is dropped on emit so + // it must not fragment full-tier buckets or the per-flag cap blows out under real traffic. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map ctx1 = new HashMap<>(); + ctx1.put("request_id", "req-1"); + final Map ctx2 = new HashMap<>(); + ctx2.put("request_id", "req-2"); + final Map ctx3 = new HashMap<>(); + ctx3.put("request_id", "req-3"); + + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, ctx1)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, false, ctx2)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 3000L, false, ctx3)); + + assertEquals(1, aggregator.fullTierSize()); + final FlagEvaluationAggregator.EvalBucket bucket = + aggregator.snapshot().fullTier.values().iterator().next(); + assertEquals(3, bucket.count); + assertFalse(bucket.observeFullEvaluationData); + assertEquals(0, bucket.prunedContextFieldCount()); + } + + @Test + void protectedPathSeparatesDifferentSubjects() { + // Different targeting keys must still fall into distinct buckets on the protected path — the + // (hashed) targeting key stays part of the aggregation identity. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, emptyMap())); + aggregator.aggregate(event("checkout", "on", "alloc1", "bob", 2000L, false, emptyMap())); + + assertEquals(2, aggregator.fullTierSize()); + } + + @Test + void fullPathStillSplitsBucketsOnDifferingContext() { + // Consent-on preserves the previous behaviour: distinct contexts remain distinct buckets so + // each raw context is emitted verbatim. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map ctx1 = new HashMap<>(); + ctx1.put("plan", "pro"); + ctx1.put("request_id", "req-1"); + final Map ctx2 = new HashMap<>(); + ctx2.put("plan", "pro"); + ctx2.put("request_id", "req-2"); + + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, true, ctx1)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, true, ctx2)); + + assertEquals(2, aggregator.fullTierSize()); + } + + private static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + return new FlagEvalEvent( + flagKey, + variant, + allocationKey, + targetingKey, + null, + evalTimeMs, + observeFullEvaluationData, + attrs); + } + + private static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); + } + + private static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { + return event(flagKey, variant, "alloc1", "user-1", 1000L, emptyMap()); + } + + private static FlagEvaluationAggregator.FullKey fullKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final String targetingKey, + final String contextKey) { + return new FlagEvaluationAggregator.FullKey( + flagKey, + variant, + allocationKey, + runtimeDefaultUsed, + errorMessage, + targetingKey, + contextKey, + false); + } + + private static FlagEvaluationAggregator.DegradedKey degradedKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage) { + return new FlagEvaluationAggregator.DegradedKey( + flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java new file mode 100644 index 00000000000..389e2b3c30f --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java @@ -0,0 +1,430 @@ +package com.datadog.openfeature.internal.flagevaluation; + +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.JsonReading; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FlagEvaluationPayloadsTest { + + private static final long EVAL_MS = 1_760_000_000_000L; + private static final Map CONTEXT = context(); + + @Test + void fullTierPayloadUsesWorkerWireShape() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + event("my-flag", "on", "alloc-x", "user-1", 1, attrs)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + assertObjectWithKey(ev.get("variant"), "on"); + assertObjectWithKey(ev.get("allocation"), "alloc-x"); + assertObjectWithKey(ev.get("flag"), "my-flag"); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + assertFalse(ev.containsKey("reason")); + } + + @Test + void eventFromFullBucketUsesFlushTimeAndEvaluationBounds() throws Exception { + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "ts-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, emptyMap(), true); + bucket.merge(EVAL_MS + 10, false); + final long flushTimeMs = EVAL_MS + 5_000; + + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, true, flushTimeMs)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + assertEquals((double) flushTimeMs, ((Number) ev.get("timestamp")).doubleValue()); + assertEquals((double) EVAL_MS, ((Number) ev.get("first_evaluation")).doubleValue()); + assertEquals((double) (EVAL_MS + 10), ((Number) ev.get("last_evaluation")).doubleValue()); + assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); + } + + @Test + void degradedTierEventOmitsTargetingKeyAndContext() throws Exception { + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "dg-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false); + + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, false, true, EVAL_MS)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void fullTierWithObserveFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "pii-flag", + "on", + "alloc1", + "jane.doe@datadoghq.com", + null, + EVAL_MS, + false, + attrs, + true); + + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, true, EVAL_MS)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + } + + @Test + void fullTierWithObserveFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() + throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "pii-flag", + "on", + "alloc1", + "jane.doe@datadoghq.com", + null, + EVAL_MS, + false, + attrs, + false); + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, false, EVAL_MS)), + CONTEXT, + 1_000_000); + final String rawJson = + new String(payloads.bodies.get(0), java.nio.charset.StandardCharsets.UTF_8); + + // The raw wire bytes must carry the hashed key and must not leak the raw PII value or the + // per-event evaluation context — these are the exact properties system-tests asserts over the + // wire. (The batch envelope has its own top-level "context" field, so we guard on the nested + // "evaluation" key instead, which only appears inside a per-event context object.) + assertTrue( + rawJson.contains( + "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b")); + assertFalse(rawJson.contains("jane.doe@datadoghq.com")); + assertFalse(rawJson.contains("\"evaluation\":")); + + final Map ev = firstEvent(parse(payloads.bodies.get(0))); + assertEquals( + "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b", + ev.get("targeting_key")); + assertFalse(ev.containsKey("context")); + } + + @Test + void splitPayloadsByEncodedSize() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 180)); + final java.util.ArrayList events = + new java.util.ArrayList<>(); + for (int i = 0; i < 4; i++) { + events.add(event("split-flag-" + i, "on", "alloc1", "user-" + i, 1, attrs)); + } + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(events, CONTEXT, 1_100); + + assertTrue(payloads.bodies.size() > 1); + int eventCount = 0; + for (final byte[] body : payloads.bodies) { + assertTrue(body.length <= 1_100); + eventCount += eventCount(parse(body)); + } + assertEquals(4, eventCount); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void oversizedFullPayloadRowIsDegradedBeforeDrop() throws Exception { + final Map attrs = new HashMap<>(); + for (int i = 0; i < 4; i++) { + attrs.put("payload-" + i, repeat('x', 200)); + } + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + event("oversized-full", "on", "alloc1", "user-1", 2, attrs)), + CONTEXT, + 512); + + assertEquals(1, payloads.bodies.size()); + assertTrue(payloads.bodies.get(0).length <= 512); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(2, payloads.degradedPayloadLimit); + final Map ev = firstEvent(parse(payloads.bodies.get(0))); + assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void oversizedFullPayloadRowStartsNewPayloadWhenDegradedRowFitsByItself() throws Exception { + final FlagEvaluationPayloads.FlagEvaluationEvent first = + event("first-flag", "on", "alloc1", "user-1", 1, emptyMap()); + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 400)); + final FlagEvaluationPayloads.FlagEvaluationEvent second = + event("second-flag", "on", "alloc1", "user-2", 3, attrs); + final FlagEvaluationPayloads.FlagEvaluationEvent degradedSecond = + second.withoutTargetingKeyAndContext(); + assertNotNull(degradedSecond); + + final int firstPayloadSize = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(first), CONTEXT, 1_000_000) + .bodies + .get(0) + .length; + final int degradedPayloadSize = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(degradedSecond), CONTEXT, 1_000_000) + .bodies + .get(0) + .length; + final int limit = Math.max(firstPayloadSize, degradedPayloadSize); + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(Arrays.asList(first, second), CONTEXT, limit); + + assertEquals(2, payloads.bodies.size()); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(3, payloads.degradedPayloadLimit); + assertEquals(1, eventCount(parse(payloads.bodies.get(0)))); + final Map ev = firstEvent(parse(payloads.bodies.get(1))); + assertObjectWithKey(ev.get("flag"), "second-flag"); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void oversizedDegradedPayloadRowIsDropped() { + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + event(repeat('f', 512), "on", "alloc1", null, 2, emptyMap())), + CONTEXT, + 128); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(2, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void oversizedFullPayloadRowIsDroppedWhenDegradedRowStillExceedsLimit() { + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + event(repeat('f', 512), "on", "alloc1", "user-1", 2, emptyMap())), + CONTEXT, + 128); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(2, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void errorPayloadSerializesErrorObject() throws Exception { + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, + "err-flag", + EVAL_MS, + EVAL_MS, + 1, + null, + null, + null, + true, + "type mismatch", + null)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + final Map error = (Map) ev.get("error"); + assertNotNull(error); + assertEquals("type mismatch", error.get("message")); + assertEquals(Boolean.TRUE, ev.get("runtime_default_used")); + } + + @Test + void emptyEventListProducesNoPayloads() { + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(java.util.Collections.emptyList(), CONTEXT, 1_000_000); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void degradingEventWithMissingOptionalFieldsKeepsOptionalObjectsAbsent() { + final FlagEvaluationPayloads.FlagEvaluationEvent degraded = + new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, + "default-flag", + EVAL_MS, + EVAL_MS, + 1, + null, + null, + "user-1", + true, + null, + null) + .withoutTargetingKeyAndContext(); + + assertNotNull(degraded); + assertNull(degraded.variant); + assertNull(degraded.allocation); + assertNull(degraded.targeting_key); + assertNull(degraded.context); + assertNull(degraded.error); + assertEquals(Boolean.TRUE, degraded.runtime_default_used); + } + + @Test + void emptyOptionalStringsAreTreatedAsAbsentWhenContextIsPresent() { + final Map attrs = new HashMap<>(); + attrs.put("tier", "gold"); + final FlagEvaluationPayloads.FlagEvaluationEvent event = + new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, "empty-optionals", EVAL_MS, EVAL_MS, 1, "", "", null, false, "", attrs); + + assertNull(event.variant); + assertNull(event.allocation); + assertNull(event.error); + assertNotNull(event.context); + + final FlagEvaluationPayloads.FlagEvaluationEvent degraded = + event.withoutTargetingKeyAndContext(); + assertNotNull(degraded); + assertNull(degraded.targeting_key); + assertNull(degraded.context); + } + + private static FlagEvaluationPayloads.FlagEvaluationEvent event( + final String flagKey, + final String variant, + final String allocation, + final String targetingKey, + final long count, + final Map attrs) { + return new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, + flagKey, + EVAL_MS, + EVAL_MS, + count, + variant, + allocation, + targetingKey, + false, + null, + attrs); + } + + private static Map firstPayload( + final FlagEvaluationPayloads.EncodedPayloads payloads) throws Exception { + assertEquals(1, payloads.bodies.size()); + return parse(payloads.bodies.get(0)); + } + + private static Map parse(final byte[] body) throws Exception { + return JsonReading.readObjectWithDoubles(body); + } + + @SuppressWarnings("unchecked") + private static Map firstEvent(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events); + assertFalse(events.isEmpty()); + return (Map) events.get(0); + } + + @SuppressWarnings("unchecked") + private static int eventCount(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events); + return events.size(); + } + + private static void assertObjectWithKey(final Object object, final String expectedKey) { + assertTrue(object instanceof Map); + assertEquals(expectedKey, ((Map) object).get("key")); + } + + private static String repeat(final char c, final int count) { + final char[] chars = new char[count]; + java.util.Arrays.fill(chars, c); + return new String(chars); + } + + private static Map context() { + final Map context = new HashMap<>(); + context.put("service", "test-service"); + return context; + } +} 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; + } + } +} From 28e28d15dfbd5a388f505764a1a4ae7344a1e931 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 09:08:19 +0200 Subject: [PATCH 15/22] feat(ffe): Add dd-openfeature SDK OpenFeature provider Add the com.datadog.openfeature.Provider OpenFeature provider, its evaluator and hooks (metrics, logging, span enrichment), and the runtime wiring the configuration delivery and event pipelines together. The SDK is still not published, the agent-coupled provider remains the shipped one. Co-Authored-By: Claude Opus 5.5 --- dd-openfeature/build.gradle.kts | 9 +- dd-openfeature/gradle.lockfile | 3 + .../FlagEvalHookHotPathBenchmark.java | 188 ++++ .../com/datadog/openfeature/DDEvaluator.java | 572 ++++++++++ .../com/datadog/openfeature/Evaluator.java | 17 + .../openfeature/FlagEvalLoggingHook.java | 143 +++ .../datadog/openfeature/FlagEvalMetrics.java | 161 +++ .../openfeature/FlagEvalMetricsHook.java | 41 + .../com/datadog/openfeature/Provider.java | 335 ++++++ .../openfeature/SpanEnrichmentHook.java | 151 +++ .../internal/FeatureFlagsRuntime.java | 228 ++++ .../evaluation/ConfigurationStore.java | 32 + .../openfeature/CanonicalFixtureTest.java | 205 ++++ .../DDEvaluatorSpanEnrichmentTest.java | 67 ++ .../datadog/openfeature/DDEvaluatorTest.java | 984 ++++++++++++++++++ .../openfeature/FlagEvalLoggingHookTest.java | 648 ++++++++++++ .../openfeature/FlagEvalMetricsHookTest.java | 126 +++ .../FlagEvalMetricsLateSdkForkedTest.java | 52 + .../openfeature/FlagEvalMetricsTest.java | 165 +++ .../com/datadog/openfeature/ProviderTest.java | 600 +++++++++++ .../openfeature/SpanEnrichmentHookTest.java | 289 +++++ .../internal/FeatureFlagsRuntimeTest.java | 238 +++++ 22 files changed, 5250 insertions(+), 4 deletions(-) create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts index f583a0cc81d..c047c567c13 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -29,16 +29,17 @@ java { 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) } -tasks.withType().configureEach { - configureCompiler(JavaVersion.VERSION_11) -} - jmh { jmhVersion = libs.versions.jmh.get() } + +tasks.withType().configureEach { + configureCompiler(JavaVersion.VERSION_11) +} diff --git a/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile index 8fa9448ffd5..07ad4ce31c3 100644 --- a/dd-openfeature/gradle.lockfile +++ b/dd-openfeature/gradle.lockfile @@ -18,6 +18,9 @@ commons-io:commons-io:2.21.0=spotbugs de.thetaphi:forbiddenapis:3.11=compileClasspath,jmhCompileClasspath dev.openfeature:sdk:1.22.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.leangen.geantyref:geantyref:1.3.16=jmhRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,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 diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java new file mode 100644 index 00000000000..34412974120 --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java @@ -0,0 +1,188 @@ +package com.datadog.openfeature; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +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; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Evaluation-thread benchmark for the OpenFeature hook: the inline cost a caller actually pays + * inside FlagEvalLoggingHook.finallyAfter, across context shapes. + * + *

This is the counterpart to FlagEvaluationHotPathBenchmark, 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 + * hookFinallyAfter is directly readable. Under consent-off the hook skips the copy entirely, which + * hookFinallyAfterConsentOff measures as the protected-path floor. + * + *

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

Run: {@code ./gradlew :dd-openfeature:jmh -Pjmh.includes=FlagEvalHookHotPathBenchmark}. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 1) +public class FlagEvalHookHotPathBenchmark { + + /** + * Context shapes. The three 100-leaf shapes (flat/100attrs, nested/10structs_10fields, + * list/10lists_10items) carry the same leaf count under different structure, so the spread + * between them isolates shape cost from leaf count. + */ + @Param({ + "flat/0attrs", + "flat/10attrs", + "flat/100attrs", + "nested/10structs_10fields", + "list/10lists_10items" + }) + public String shape; + + private HookContext hookContext; + private FlagEvaluationDetails consentOnDetails; + private FlagEvaluationDetails consentOffDetails; + private FlagEvalLoggingHook hook; + + @Setup(Level.Trial) + public void setUp() { + final MutableContext ctx = buildContext(shape); + hookContext = + HookContext.builder() + .flagKey("bench-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + + consentOnDetails = details(true); + consentOffDetails = details(false); + + // Discarding writer: isolates hook-inline cost from queue mechanics. + final FlagEvaluationWriter writer = new NoOpWriter(); + hook = new FlagEvalLoggingHook<>(() -> writer); + } + + /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ + @Benchmark + public void hookFinallyAfter() { + hook.finallyAfter(hookContext, consentOnDetails, Collections.emptyMap()); + } + + /** Protected-path floor: consent-off skips the context copy, leaving scalar work plus enqueue. */ + @Benchmark + public void hookFinallyAfterConsentOff() { + hook.finallyAfter(hookContext, consentOffDetails, Collections.emptyMap()); + } + + /** The bounded context copy alone - the component that scales with context shape. */ + @Benchmark + public void contextCopy(final Blackhole blackhole) { + blackhole.consume(DDEvaluator.copyPrunedContext(hookContext.getCtx())); + } + + private static FlagEvaluationDetails details(final boolean observeFullEvaluationData) { + return FlagEvaluationDetails.builder() + .flagKey("bench-flag") + .value("on-value") + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata( + ImmutableMetadata.builder() + .addString("allocationKey", "alloc-1") + .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) + .addBoolean( + DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build()) + .build(); + } + + private static MutableContext buildContext(final String shape) { + final MutableContext ctx = new MutableContext("bench-user"); + if ("flat/0attrs".equals(shape)) { + return ctx; + } + if ("flat/10attrs".equals(shape)) { + return addFlat(ctx, 10); + } + if ("flat/100attrs".equals(shape)) { + return addFlat(ctx, 100); + } + if ("nested/10structs_10fields".equals(shape)) { + for (int i = 0; i < 10; i++) { + final Map inner = new HashMap<>(); + for (int j = 0; j < 10; j++) { + inner.put("field" + j, new Value("value" + j)); + } + ctx.add("struct" + i, new ImmutableStructure(inner)); + } + return ctx; + } + if ("list/10lists_10items".equals(shape)) { + for (int i = 0; i < 10; i++) { + final List items = new ArrayList<>(10); + for (int j = 0; j < 10; j++) { + items.add(new Value("value" + j)); + } + ctx.add("list" + i, items); + } + return ctx; + } + throw new IllegalArgumentException("unknown benchmark shape: " + shape); + } + + private static MutableContext addFlat(final MutableContext ctx, final int count) { + for (int i = 0; i < count; i++) { + ctx.add("field" + i, "value" + i); + } + return ctx; + } + + /** Discards events so only hook-inline work is measured. */ + private static final class NoOpWriter implements FlagEvaluationWriter { + @Override + public void enqueue(final FlagEvalEvent event) {} + + @Override + public boolean hasCapacityForEnqueue() { + return true; + } + + @Override + public void countPreQueueOverflow() {} + + @Override + public void countContextTruncated(final String reason) {} + } +} 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/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java new file mode 100644 index 00000000000..10176fe09a2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java @@ -0,0 +1,17 @@ +package com.datadog.openfeature; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.concurrent.TimeUnit; + +interface Evaluator { + + boolean initialize(long timeout, TimeUnit timeUnit, EvaluationContext context) throws Exception; + + boolean hasConfiguration(); + + void shutdown(); + + ProviderEvaluation evaluate( + Class target, String key, T defaultValue, EvaluationContext context); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java new file mode 100644 index 00000000000..5baab2df44a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -0,0 +1,143 @@ +package com.datadog.openfeature; + +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; +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.Collections; +import java.util.Map; +import java.util.function.Supplier; + +/** + * OpenFeature Hook that captures flag evaluation events for EVP flagevaluation emission. + * + *

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

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

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

The 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 { + + /** Pipeline resolver, returning {@code null} when evaluation counts are not delivered. */ + private final Supplier writerSupplier; + + FlagEvalLoggingHook(final Supplier writerSupplier) { + this.writerSupplier = writerSupplier; + } + + /** + * Capture + non-blocking enqueue only; no flattening, aggregation, or I/O. Runs at the finally + * stage so it covers success, error, and default-value paths. + * + *

The context snapshot taken here scales with context size/nesting - see the class javadoc for + * why it cannot be deferred. + */ + @Override + public void finallyAfter( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + try { + if (details == null) { + return; + } + final FlagEvaluationWriter w = writerSupplier.get(); + if (w == null) { + return; + } + + // Pre-queue guard: if the queue is already saturated, avoid the context-copy work whose + // result would be discarded on offer(). Best-effort — the queue can still be full at + // offer() time — but flips a full snapshot into an O(1) check on the drop path. + if (!w.hasCapacityForEnqueue()) { + w.countPreQueueOverflow(); + return; + } + + // Scalar extraction - individual typed metadata reads, no JSON, no flattening + final String flagKey = details.getFlagKey(); + final ImmutableMetadata metadata = details.getFlagMetadata(); + + // allocationKey: "allocationKey" (camelCase) - consistent with FlagEvalMetricsHook.java + final String allocationKey = metadata != null ? metadata.getString("allocationKey") : null; + + // eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time. + // ImmutableMetadata.getLong available since sdk 1.4+. + final Long evalTimeObj = metadata != null ? metadata.getLong("__dd_eval_timestamp_ms") : null; + final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis(); + + // variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the + // evaluated value. A null variant means no variant was selected (runtime default). + final String variant = details.getVariant(); + + // targetingKey from evaluation context + final String targetingKey = + ctx != null && ctx.getCtx() != null ? ctx.getCtx().getTargetingKey() : null; + + // Consent is read from metadata stamped by DDEvaluator (pinned to its ServerConfiguration). + // Missing key = non-DD provider → false, the privacy-preserving default. + final Boolean consentFromMetadata = + metadata != null + ? metadata.getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA) + : null; + final boolean observeFullEvaluationData = consentFromMetadata != null && consentFromMetadata; + + // Error message: prefer the human-readable message under consent-on; under consent-off the + // provider's raw message can echo evaluation-context values (e.g. NumberFormatException: + // "For input string: \"jane.doe@...\""), so replace it with the ErrorCode name — a stable, + // PII-free signal. Same substitution path is used when the message is absent regardless of + // consent (some providers populate only the code). Null on success. + String errorMessage = observeFullEvaluationData ? details.getErrorMessage() : null; + if ((errorMessage == null || errorMessage.isEmpty()) && details.getErrorCode() != null) { + errorMessage = details.getErrorCode().name(); + } + if (errorMessage != null && errorMessage.isEmpty()) { + errorMessage = null; + } + + // On the protected path (consent-off) the evaluation context is dropped on emit and never + // consulted by the aggregator, so skip the bounded copy entirely — the copy cost only + // applies under consent-on. + final Map attrs; + if (observeFullEvaluationData && ctx != null && ctx.getCtx() != null) { + // Bounded copy of the caller's mutable context (see DDEvaluator.copyPrunedContext for + // every retained-size cap). Runs inline because the event is consumed asynchronously + // and the source context is caller-owned; work is proportional to what is retained. + final DDEvaluator.CopyResult copy = DDEvaluator.copyPrunedContext(ctx.getCtx()); + if (copy.truncatedReason != null) { + w.countContextTruncated(copy.truncatedReason); + } + attrs = copy.attrs; + } else { + attrs = Collections.emptyMap(); + } + + w.enqueue( + new FlagEvalEvent( + flagKey, + variant, + allocationKey, + targetingKey, + errorMessage, + evalTimeMs, + observeFullEvaluationData, + attrs)); + } catch (LinkageError e) { + // Never let EVP recording break flag evaluation + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java new file mode 100644 index 00000000000..a108e0fd582 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java @@ -0,0 +1,161 @@ +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; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import java.io.Closeable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class FlagEvalMetrics implements Closeable { + + private static final Logger log = LoggerFactory.getLogger(FlagEvalMetrics.class); + + private static final String METER_NAME = "ddtrace.openfeature"; + private static final String METRIC_NAME = "feature_flag.evaluations"; + private static final String METRIC_UNIT = "{evaluation}"; + private static final String METRIC_DESC = "Number of feature flag evaluations"; + + private static final AttributeKey ATTR_FLAG_KEY = + AttributeKey.stringKey("feature_flag.key"); + private static final AttributeKey ATTR_VARIANT = + AttributeKey.stringKey("feature_flag.result.variant"); + private static final AttributeKey ATTR_REASON = + AttributeKey.stringKey("feature_flag.result.reason"); + private static final AttributeKey ATTR_ERROR_TYPE = AttributeKey.stringKey("error.type"); + private static final AttributeKey ATTR_ALLOCATION_KEY = + AttributeKey.stringKey("feature_flag.result.allocation_key"); + + private volatile LongCounter counter; + 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 { + 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"); + } + 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 = resolveCounter(); + if (c == null) { + return; + } + try { + AttributesBuilder builder = + Attributes.builder() + .put(ATTR_FLAG_KEY, flagKey) + .put(ATTR_VARIANT, variant != null ? variant : "") + .put(ATTR_REASON, reason != null ? reason.toLowerCase() : "unknown"); + + if (errorCode != null) { + builder.put(ATTR_ERROR_TYPE, errorCode.name().toLowerCase()); + } + + if (allocationKey != null && !allocationKey.isEmpty()) { + builder.put(ATTR_ALLOCATION_KEY, allocationKey); + } + + c.add(1, builder.build()); + } catch (Exception e) { + log.debug("Failed to record flag evaluation metric for {}", flagKey, e); + } + } + + @Override + public void close() { + shutdown(); + } + + synchronized void shutdown() { + closed = true; + counter = null; + telemetry = null; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java new file mode 100644 index 00000000000..d63cbe56d8c --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java @@ -0,0 +1,41 @@ +package com.datadog.openfeature; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.Map; + +class FlagEvalMetricsHook implements Hook { + + private final FlagEvalMetrics metrics; + + FlagEvalMetricsHook(FlagEvalMetrics metrics) { + this.metrics = metrics; + } + + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + if (metrics == null || details == null) { + return; + } + try { + String flagKey = details.getFlagKey(); + String variant = details.getVariant(); + String reason = details.getReason(); + ErrorCode errorCode = details.getErrorCode(); + + String allocationKey = null; + ImmutableMetadata metadata = details.getFlagMetadata(); + if (metadata != null) { + allocationKey = metadata.getString("allocationKey"); + } + + metrics.record(flagKey, variant, reason, errorCode, allocationKey); + } catch (Exception e) { + // Never let metrics recording break flag evaluation + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java new file mode 100644 index 00000000000..62f1ab751e5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -0,0 +1,335 @@ +package com.datadog.openfeature; + +import static java.util.concurrent.TimeUnit.SECONDS; + +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; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; +import dev.openfeature.sdk.exceptions.OpenFeatureError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** 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 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; + private final FlagEvalMetricsHook flagEvalMetricsHook; + // Span enrichment: null unless the gate is on, so the feature has no idle overhead when off. + private final SpanEnrichmentHook spanEnrichmentHook; + // Precomputed hook list returned by getProviderHooks() on every evaluation. Immutable and built + // once so gate-off evaluation allocates nothing on this hot path. + private final List providerHooks; + + /** 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); + } + + Provider(final Options options, final Evaluator evaluator) { + this(options, evaluator, null); + } + + /** + * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test + * seam); when null, the gate is read from the settings. + */ + Provider( + final Options options, + final Evaluator evaluator, + 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 { + metrics = new FlagEvalMetrics(); + hook = new FlagEvalMetricsHook(metrics); + } catch (LinkageError | Exception e) { + // This outer catch fires when the metrics helper itself can't load (OTel API absent). + log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); + } + this.flagEvalMetrics = metrics; + this.flagEvalMetricsHook = hook; + + // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle + // per-evaluation overhead. + final boolean spanEnrichmentEnabled = + spanEnrichmentEnabledOverride != null + ? spanEnrichmentEnabledOverride + : 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. + final List hooks = new ArrayList<>(3); + if (flagEvalMetricsHook != null) { + hooks.add(flagEvalMetricsHook); + } + // 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); + } + this.providerHooks = + hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); + + // Announce the span-enrichment state at startup (matches the reference implementation). + // "enabled" only when the gate is on (the capture hook was constructed), otherwise "disabled". + if (spanEnrichmentHook != null) { + log.info("{} span enrichment enabled", METADATA); + } else { + log.info("{} span enrichment disabled", METADATA); + } + } + + @Override + public void initialize(final EvaluationContext context) throws Exception { + initializationState.set(InitializationState.INITIALIZING); + try { + evaluator = buildEvaluator(); + if (!evaluator.initialize(options.getTimeout(), options.getUnit(), context)) { + if (markInitialConfigReceivedReady()) { + return; + } + markInitializationError(); + throw new ProviderNotReadyError( + "Provider timed-out while waiting for initial configuration"); + } + if (!evaluator.hasConfiguration() || !markSuccessfulInitializationReady()) { + markInitializationError(); + throw new ProviderNotReadyError( + "Provider timed-out while waiting for initial configuration"); + } + } catch (final OpenFeatureError e) { + markInitializationError(); + throw e; + } catch (final Throwable e) { + markInitializationError(); + throw new FatalError( + "Failed to initialize Datadog Feature Flags provider: " + e.getMessage(), e); + } + } + + void onConfigurationChange() { + if (evaluator == null || !evaluator.hasConfiguration()) { + onConfigurationUnavailable(); + return; + } + + final InitializationState state = initializationState.get(); + if (state == InitializationState.INITIALIZING) { + initializationState.compareAndSet( + InitializationState.INITIALIZING, InitializationState.INITIAL_CONFIG_RECEIVED); + return; + } + if (state == InitializationState.INITIAL_CONFIG_RECEIVED) { + return; + } + if (state == InitializationState.ERROR + && initializationState.compareAndSet( + InitializationState.ERROR, InitializationState.READY)) { + emit( + ProviderEvent.PROVIDER_READY, + ProviderEventDetails.builder().message("Provider ready").build()); + return; + } + if (initializationState.get() != InitializationState.READY) { + return; + } + emit( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + ProviderEventDetails.builder().message("New configuration received").build()); + } + + private void onConfigurationUnavailable() { + if (initializationState.compareAndSet( + InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.ERROR)) { + return; + } + if (!initializationState.compareAndSet(InitializationState.READY, InitializationState.ERROR)) { + return; + } + emit( + ProviderEvent.PROVIDER_ERROR, + ProviderEventDetails.builder() + .message("Configuration unavailable") + .errorCode(ErrorCode.PROVIDER_NOT_READY) + .build()); + } + + private boolean markInitialConfigReceivedReady() { + return initializationState.get() == InitializationState.READY + || initializationState.compareAndSet( + InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.READY); + } + + private boolean markSuccessfulInitializationReady() { + return markInitialConfigReceivedReady() + || initializationState.compareAndSet( + InitializationState.INITIALIZING, InitializationState.READY); + } + + private void markInitializationError() { + InitializationState state = initializationState.get(); + while (state != InitializationState.READY && state != InitializationState.ERROR) { + if (initializationState.compareAndSet(state, InitializationState.ERROR)) { + return; + } + state = initializationState.get(); + } + } + + private Evaluator buildEvaluator() { + if (evaluator != null) { + return evaluator; + } + 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 + public List getProviderHooks() { + return providerHooks; + } + + @Override + public void shutdown() { + if (flagEvalMetrics != null) { + flagEvalMetrics.shutdown(); + } + // Span enrichment needs no provider-close cleanup here: the capture hook holds no tracer state. + if (evaluator != null) { + evaluator.shutdown(); + } + } + + // Visible for tests: expose whether span enrichment is wired (gate-on) without leaking the impl. + SpanEnrichmentHook spanEnrichmentHook() { + return spanEnrichmentHook; + } + + @Override + public Metadata getMetadata() { + return this; + } + + @Override + public String getName() { + return METADATA; + } + + @Override + public ProviderEvaluation getBooleanEvaluation( + final String key, final Boolean defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Boolean.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getStringEvaluation( + final String key, final String defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(String.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + final String key, final Integer defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Integer.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getDoubleEvaluation( + final String key, final Double defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Double.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getObjectEvaluation( + final String key, final Value defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Value.class, key, defaultValue, ctx); + } + + private enum InitializationState { + NOT_STARTED, + INITIALIZING, + INITIAL_CONFIG_RECEIVED, + READY, + 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; + return this; + } + + public long getTimeout() { + return timeout; + } + + public TimeUnit getUnit() { + return unit; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java new file mode 100644 index 00000000000..525ba9badf9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java @@ -0,0 +1,151 @@ +package com.datadog.openfeature; + +import com.datadog.openfeature.internal.connector.SpanEnricher; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OpenFeature {@code finally} hook that captures feature-flag evaluation metadata for APM span + * enrichment. Registered from {@link Provider#getProviderHooks()} only when the gate is on. For + * each relevant evaluation it 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): + * + *

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

All work is wrapped in try/catch — enrichment must NEVER break flag evaluation. + */ +class SpanEnrichmentHook implements Hook { + + private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentHook.class); + + // The metadata keys the DDEvaluator attaches for span enrichment (single source of truth there). + static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; + static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; + + private final SpanEnricher enricher; + + SpanEnrichmentHook(final SpanEnricher enricher) { + this.enricher = enricher; + } + + @Override + public void finallyAfter( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + if (details == null) { + return; + } + try { + final ImmutableMetadata metadata = details.getFlagMetadata(); + final Integer serialId = metadata != null ? metadata.getInteger(METADATA_SERIAL_ID) : null; + if (serialId != null) { + final boolean doLog = Boolean.TRUE.equals(metadata.getBoolean(METADATA_DO_LOG)); + 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 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. + log.debug("Span-enrichment capture failed for flag {}", details.getFlagKey(), t); + } + } + + private static String targetingKey(final HookContext ctx) { + if (ctx == null) { + return null; + } + final EvaluationContext evaluationContext = ctx.getCtx(); + if (evaluationContext == null) { + return null; + } + final String key = evaluationContext.getTargetingKey(); + return (key == null || key.isEmpty()) ? null : key; + } + + /** + * Unwraps an OpenFeature {@link Value} to a native Java representation so the runtime default can + * cross the (JDK-types-only) seam and be JSON-serialized on the write side exactly like Node's + * {@code JSON.stringify}. Non-{@code Value} inputs (already native) are returned as-is. + */ + static Object unwrapDefaultValue(final Object value) { + return value instanceof Value ? unwrapValue((Value) value) : value; + } + + /** + * Recursively unwraps an OpenFeature {@link Value} into its native Java representation: + * structures become {@code Map}, lists become {@code List}, and scalars + * become their boxed value (or {@code null}). Nested {@link Value}s are unwrapped at every level + * so a structure containing further structures/lists serializes correctly. + */ + private static Object unwrapValue(final Value value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isStructure()) { + final Structure structure = value.asStructure(); + final Map map = new LinkedHashMap<>(); + if (structure != null) { + for (final String key : structure.keySet()) { + map.put(key, unwrapValue(structure.getValue(key))); + } + } + return map; + } + if (value.isList()) { + final List list = value.asList(); + final List out = new ArrayList<>(list == null ? 0 : list.size()); + if (list != null) { + for (final Value element : list) { + out.add(unwrapValue(element)); + } + } + return out; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isString()) { + return value.asString(); + } + if (value.isNumber()) { + // Preserve integral vs fractional so the rendered JSON number matches Node. + final Double d = value.asDouble(); + if (d != null && d == Math.rint(d) && !Double.isInfinite(d)) { + final Integer i = value.asInteger(); + if (i != null) { + return i; + } + } + return d; + } + final Instant instant = value.asInstant(); + if (instant != null) { + return instant.toString(); + } + // Unknown shape: fall back to the wrapped object's own representation. + return value.asObject(); + } +} diff --git a/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/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/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/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java new file mode 100644 index 00000000000..507d1bca061 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java @@ -0,0 +1,984 @@ +package com.datadog.openfeature; + +import static dev.openfeature.sdk.Reason.ERROR; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.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.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +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.time.Instant; +import java.util.ArrayList; +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class DDEvaluatorTest { + + private static DDEvaluator newEvaluator(final Runnable configCallback) { + return new DDEvaluator(configCallback, Connector.NONE, TestSettings.of()); + } + + /** 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 + Arguments.of(String.class, "hello", "hello"), + Arguments.of(String.class, 123, "123"), + Arguments.of(String.class, true, "true"), + Arguments.of(String.class, 3.14, "3.14"), + Arguments.of(String.class, null, null), + + // Boolean mappings + Arguments.of(Boolean.class, true, true), + Arguments.of(Boolean.class, false, false), + Arguments.of(Boolean.class, "true", true), + Arguments.of(Boolean.class, "false", false), + Arguments.of(Boolean.class, "TRUE", true), + Arguments.of(Boolean.class, "FALSE", false), + Arguments.of(Boolean.class, 1, true), + Arguments.of(Boolean.class, 0, false), + Arguments.of(Boolean.class, null, null), + + // Integer mappings + Arguments.of(Integer.class, 42, 42), + Arguments.of(Integer.class, "42", 42), + Arguments.of(Integer.class, 3.14, 3), + Arguments.of(Integer.class, "3.14", 3), + Arguments.of(Integer.class, null, null), + + // Double mappings + Arguments.of(Double.class, 3.14, 3.14), + Arguments.of(Double.class, "3.14", 3.14), + Arguments.of(Double.class, 42, 42.0), + Arguments.of(Double.class, "42", 42.0), + Arguments.of(Double.class, null, null), + + // Value mappings (OpenFeature Value objects) + Arguments.of(Value.class, "hello", Value.objectToValue("hello")), + Arguments.of(Value.class, 42, Value.objectToValue(42)), + Arguments.of(Value.class, 3.14, Value.objectToValue(3.14)), + Arguments.of(Value.class, true, Value.objectToValue(true)), + Arguments.of(Value.class, null, null), + + // Unsupported + Arguments.of(Long.class, 42L, IllegalArgumentException.class), + }; + } + + @ParameterizedTest + @MethodSource("valueMappingTestCases") + public void testValueMapping(final Class target, final Object value, final Object expected) { + if (expected == IllegalArgumentException.class) { + assertThrows(IllegalArgumentException.class, () -> DDEvaluator.mapValue(target, value)); + } else { + final Object result = DDEvaluator.mapValue(target, value); + assertThat(result, equalTo(expected)); + } + } + + @Test + public void testEvaluateNoConfig() { + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + } + + @Test + public void testInitializeTimesOutWithoutConfig() throws Exception { + final Runnable configCallback = mock(Runnable.class); + 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 { + 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(new ServerConfiguration("", "", false, null, emptyMap())); + assertThat(initialized.get(5, SECONDS), equalTo(true)); + } finally { + executor.shutdownNow(); + evaluator.shutdown(); + } + } + } + + @Test + public void testEvaluateNoContext() { + 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())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.INVALID_CONTEXT)); + } + + @Test + public void testNoAllocations() { + final Map flags = new HashMap<>(); + flags.put("null-allocation", new Flag("target", true, null, null, null)); + flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); + + ProviderEvaluation details = evaluator.evaluate(Integer.class, "null-allocation", 23, ctx); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.GENERAL)); + + details = evaluator.evaluate(Integer.class, "empty-allocation", 23, ctx); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat(details.getErrorCode(), nullValue()); + } + + @Test + public void testEvaluateUnsignedShardRange() { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + // The selected shard is above Integer.MAX_VALUE, so this test proves that evaluation uses + // unsigned 32-bit semantics. + final Shard shard = + new Shard( + "salt", + 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); + final Map flags = new HashMap<>(); + flags.put( + "target", + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + + final EvaluationContext context = + new MutableContext("target").setTargetingKey("high-shard-user"); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, context); + + assertThat(details.getValue(), equalTo(1)); + assertThat(details.getReason(), equalTo("SPLIT")); + assertThat(details.getVariant(), equalTo("on")); + } + + // ---- observeFullEvaluationData metadata is stamped from the evaluator's ServerConfiguration + // ---- + // + // Every code path that returns a ProviderEvaluation must stamp the consent boolean so downstream + // hooks can honour it. These tests exercise each stamp site with both consent values (on/off) so + // a mutation to any stamp — deleting the line, hardcoding the value — flips at least one + // assertion. + + // -- success path: resolveVariant (variant metadata builder) -- + + @Test + public void observeFullEvaluationDataStampedTrueOnResolvedVariant() { + final ProviderEvaluation details = evaluateMatchingFlag(true); + + assertThat(details.getReason(), equalTo("STATIC")); + assertThat(details.getVariant(), equalTo("on")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { + // Symmetric consent-off assertion. Paired with the consent-on test above this pins the + // resolveVariant metadata line (DDEvaluator.java: METADATA_OBSERVE_FULL_EVALUATION_DATA) so + // deleting it or hardcoding either value would fail at least one assertion. + final ProviderEvaluation details = evaluateMatchingFlag(false); + + assertThat(details.getReason(), equalTo("STATIC")); + assertThat(details.getVariant(), equalTo("on")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- DISABLED path: flag.enabled=false -- + + @Test + public void observeFullEvaluationDataStampedTrueOnDisabledFlag() { + final ProviderEvaluation details = evaluateDisabledFlag(true); + + assertThat(details.getReason(), equalTo("DISABLED")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnDisabledFlag() { + final ProviderEvaluation details = evaluateDisabledFlag(false); + + assertThat(details.getReason(), equalTo("DISABLED")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- DEFAULT path: no allocation matches -- + + @Test + public void observeFullEvaluationDataStampedTrueOnDefault() { + // Allocation exists but has empty splits, so the loop finishes without returning and we fall + // through to the DEFAULT branch. + final ProviderEvaluation details = evaluateWithEmptySplits(true); + + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnDefault() { + final ProviderEvaluation details = evaluateWithEmptySplits(false); + + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- error paths: FLAG_NOT_FOUND / PROVIDER_NOT_READY (via consentMetadata in error()) -- + + @Test + public void observeFullEvaluationDataStampedOnFlagNotFoundError() { + // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via + // FLAG_NOT_FOUND — kept for that stamp site, correctly named. + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "unknown-flag", 23, ctx); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.FLAG_NOT_FOUND)); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } + + @Test + public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + @Test + public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { + // The field is boxed so 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 = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, ctx); + + // Flags still evaluate — availability preserved despite the malformed consent field. + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // ---- exposure events carry the split's serial id ---- + + @Test + public void exposureCarriesTheSplitSerialId() { + assertEquals(Integer.valueOf(340132), exposureFor(340132).serial_id); + } + + @Test + public void exposureCarriesSerialIdZero() { + assertEquals(Integer.valueOf(0), exposureFor(0).serial_id); + } + + @Test + public void exposureOmitsSerialIdWhenTheSplitHasNone() { + assertNull(exposureFor(null).serial_id); + } + + /** + * 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 + * the serial id does not travel via the enrichment-gated evaluation metadata. + */ + private static ExposureEvent exposureFor(final Integer serialId) { + final List dispatched = new ArrayList<>(); + 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); + } + + // Builds a flag that reaches resolveVariant: enabled, one allocation with no rules, one split + // with empty shards (so the shard-match branch is skipped and the split is picked immediately), + // and a single "on" variant whose value maps to the requested Integer type. + private static ProviderEvaluation evaluateMatchingFlag( + final boolean observeFullEvaluationData) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), null); + final Allocation allocation = + new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + return evaluateFlag( + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), + observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateDisabledFlag( + final boolean observeFullEvaluationData) { + return evaluateFlag( + new Flag("target", false, ValueType.INTEGER, emptyMap(), null), observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateWithEmptySplits( + final boolean observeFullEvaluationData) { + // Enabled, allocations present, allocation active, no rules, empty splits → falls through the + // for-loop to the DEFAULT return. + final Allocation allocation = + new Allocation("alloc-1", null, null, null, emptyList(), Boolean.FALSE); + return evaluateFlag( + new Flag("target", true, ValueType.INTEGER, emptyMap(), singletonList(allocation)), + observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateFlag( + final Flag flag, final boolean observeFullEvaluationData) { + final Map flags = new HashMap<>(); + flags.put("target", flag); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); + return evaluator.evaluate(Integer.class, "target", 23, ctx); + } + + // ---- error message redaction respects observeFullEvaluationData ---- + + @Test + public void numericConditionOnTargetingKeyDropsExceptionMessageUnderConsentOff() { + // Rule {attribute:"id", operator:GT, value:0} + "id" not in context → + // DDEvaluator.resolveAttribute + // falls back to the targeting key, so Double.parseDouble("jane.doe@datadoghq.com") throws + // NumberFormatException. The exception message echoes the raw context value verbatim, so it + // must be dropped when observeFullEvaluationData=false. + final ProviderEvaluation details = + evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); + assertNull(details.getErrorMessage(), "consent-off must not surface the raw exception message"); + } + + @Test + public void numericConditionOnTargetingKeyPreservesExceptionMessageUnderConsentOn() { + // Symmetric case: with consent on, the raw exception message flows through unchanged so + // operators keep the diagnostic detail they opted in to. + final ProviderEvaluation details = + evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", true); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); + assertThat(details.getErrorMessage(), equalTo("For input string: \"jane.doe@datadoghq.com\"")); + } + + @Test + public void numericConditionOnTargetingKeyErrorMessageNeverContainsPiiUnderConsentOff() { + // Belt-and-suspenders: independent of the exact null/empty form, the raw PII value must never + // appear in the message under consent-off. Guards against future changes that might replace + // null with a redacted string or a code-name suffix. + final ProviderEvaluation details = + evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); + + final String message = details.getErrorMessage(); + assertFalse( + message != null && message.contains("jane.doe@datadoghq.com"), + "consent-off errorMessage must not contain raw context values"); + } + + private static ProviderEvaluation evaluateWithNumericRuleOnId( + final String targetingKey, final boolean observeFullEvaluationData) { + final Map flags = new HashMap<>(); + final List rules = + singletonList( + new Rule(singletonList(new ConditionConfiguration(ConditionOperator.GT, "id", 0)))); + // Split must be non-empty so the allocation is considered a match target; its contents don't + // matter because the rule throws before a split is picked. + final Allocation allocation = + new Allocation("alloc", rules, null, null, emptyList(), Boolean.FALSE); + flags.put( + "num-rule", + new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); + final DDEvaluator evaluator = 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 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 = + new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); + + assertThat( + DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); + assertThat(DDEvaluator.isAllocationActive(allocation, startAt), equalTo(true)); + assertThat(DDEvaluator.isAllocationActive(allocation, endAt), equalTo(true)); + assertThat(DDEvaluator.isAllocationActive(allocation, endAt.plusNanos(1_000)), equalTo(false)); + } + + // --- SemVer condition evaluation tests (ported from Go evaluator_test.go) --- + + private static Flag semverFlag(final ConditionOperator operator, final String comparand) { + final ParsedSemver parsed = ParsedSemver.parse(comparand); + final ConditionConfiguration condition = + new ConditionConfiguration(operator, "version", comparand); + condition.semverComparand = parsed; + final Rule rule = new Rule(singletonList(condition)); + final Split split = new Split(emptyList(), "on", null, null); + final Allocation allocation = + 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)); + } + + private static EvaluationContext semverContext(final Object version) { + final Map attributes = new HashMap<>(); + if (version != null) { + attributes.put("version", version); + } + final MutableContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + context.setTargetingKey("subject"); + return context; + } + + static Arguments[] semverConditionTestCases() { + return new Arguments[] { + // Equal + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3", "1.2.3", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.4", "1.2.3", false), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3.4.5.6", "1.2.3.4.5.6", true), + Arguments.of(ConditionOperator.SEMVER_GT, "1.2.3.4.5.7", "1.2.3.4.5.6", true), + // Not equal + Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.4", "1.2.3", true), + Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.3", "1.2.3", false), + // Less than + Arguments.of(ConditionOperator.SEMVER_LT, "1.9.9", "2.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LT, "2.0.0", "2.0.0", false), + // Less than or equal + Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.0", "2.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.1", "2.0.0", false), + // Greater than + Arguments.of(ConditionOperator.SEMVER_GT, "1.0.1", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GT, "1.0.0", "1.0.0", false), + // Greater than or equal + Arguments.of(ConditionOperator.SEMVER_GTE, "1.0.0", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GTE, "0.9.9", "1.0.0", false), + // Prerelease ordering + Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.1", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.2", "1.0.0-beta.11", true), + // Build metadata is ignored + Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+exp.sha.5114f85", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_NEQ, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_LT, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_LTE, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GT, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.0.0+linux", "1.0.0+darwin", true), + // Invalid attribute does not match + Arguments.of(ConditionOperator.SEMVER_NEQ, "not-a-version", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "1.2", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GTE, "v1.2.3", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "18446744073709551616.0.0", "1.0.0", false), + // Non-string attribute does not match + Arguments.of(ConditionOperator.SEMVER_EQ, 1.2, "1.2.0", false), + }; + } + + @ParameterizedTest(name = "{0} attr={1} comparand={2} -> {3}") + @MethodSource("semverConditionTestCases") + public void testEvaluateSemverCondition( + final ConditionOperator operator, + final Object attribute, + final String comparand, + final boolean wantMatch) { + final Map flags = new HashMap<>(); + flags.put("test-flag", semverFlag(operator, comparand)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); + + if (wantMatch) { + assertThat(details.getValue(), equalTo(true)); + assertThat(details.getReason(), equalTo("TARGETING_MATCH")); + } else { + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo("DEFAULT")); + } + } + + @Test + public void testEvaluateSemverConditionMissingAttribute() { + final Map flags = new HashMap<>(); + flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo("DEFAULT")); + } + + @Test + public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { + // A flag with an invalid semver comparand is dropped during parsing. + // The evaluator should return PARSE_ERROR when the flag is queried. + final Map flags = new HashMap<>(); + final Map invalidFlags = new HashMap<>(); + invalidFlags.put("invalid-semver", "invalid_semver_comparand"); + final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); + config.invalidFlags = invalidFlags; + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(config); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); + } + + @Test + public void testEvaluateMalformedFlagReturnsParseError() { + final Map flags = new HashMap<>(); + final Map invalidFlags = new HashMap<>(); + invalidFlags.put("malformed-flag", "invalid_flag"); + final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); + config.invalidFlags = invalidFlags; + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(config); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "malformed-flag", false, new MutableContext()); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); + } + + private static Arguments[] flatteningTestCases() { + final List arguments = new ArrayList<>(); + arguments.add(Arguments.of(emptyMap(), emptyMap())); + arguments.add( + Arguments.of( + mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null), + mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null))); + arguments.add( + Arguments.of( + mapOf("list", asList(1, 2, singletonList(4))), + mapOf("list[0]", 1, "list[1]", 2, "list[2][0]", 4))); + arguments.add( + Arguments.of( + mapOf("map", mapOf("key1", 1, "key2", 2, "key3", mapOf("key4", 4))), + mapOf("map.key1", 1, "map.key2", 2, "map.key3.key4", 4))); + arguments.add( + Arguments.of( + mapOf("plan", "gold", "cohort", "gold"), mapOf("plan", "gold", "cohort", "gold"))); + final Instant instant = Instant.parse("2026-07-10T12:34:56Z"); + arguments.add(Arguments.of(mapOf("instant", instant), mapOf("instant", instant.toString()))); + return arguments.toArray(new Arguments[0]); + } + + @MethodSource("flatteningTestCases") + @ParameterizedTest + public void testFlattening( + final Map attributes, final Map expected) { + final EvaluationContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + final Map result = DDEvaluator.flattenContext(context); + + assertThat(result.size(), equalTo(expected.size())); + for (final Map.Entry entry : expected.entrySet()) { + assertThat(result, hasEntry(entry.getKey(), entry.getValue())); + } + } + + @Test + public void testDeeplyNestedContextIsTruncatedRatherThanOverflowingTheStack() { + Value nested = new Value("leaf"); + for (int i = 0; i < 10_000; i++) { + nested = new Value(singletonList(nested)); + } + final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); + + final Map result = DDEvaluator.flattenContext(context); + + final StringBuilder truncatedKey = new StringBuilder("deep"); + for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { + truncatedKey.append("[0]"); + } + assertThat(result.size(), equalTo(1)); + assertThat(result, hasEntry(truncatedKey.toString(), null)); + } + + @Test + public void testCopyPrunedContextCapsTopLevelFieldCount() { + final MutableContext context = new MutableContext(); + for (int i = 0; i < DDEvaluator.MAX_CONTEXT_FIELDS + 100; i++) { + context.add(String.format("k%04d", i), "v"); + } + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs.size(), equalTo(DDEvaluator.MAX_CONTEXT_FIELDS)); + assertThat(result.truncatedReason, equalTo("max_context_fields")); + } + + @Test + public void testCopyPrunedContextSkipsOversizedStringValues() { + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add("drop", new String(longChars)); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("keep", "ok")); + assertThat(result.attrs.containsKey("drop"), equalTo(false)); + assertThat(result.truncatedReason, equalTo("max_value_length")); + } + + @Test + public void testCopyPrunedContextSkipsOversizedKeys() { + final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; + java.util.Arrays.fill(longKeyChars, 'k'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add(new String(longKeyChars), "drop"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("keep", "ok")); + assertThat(result.attrs.size(), equalTo(1)); + assertThat(result.truncatedReason, equalTo("max_key_length")); + } + + @Test + public void testCopyPrunedContextCapsListWidth() { + final List wide = new java.util.ArrayList<>(); + for (int i = 0; i < DDEvaluator.MAX_LIST_ELEMENTS + 50; i++) { + wide.add(Value.objectToValue("v" + i)); + } + final EvaluationContext context = new MutableContext().add("list", wide); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs.containsKey("list[0]"), equalTo(true)); + assertThat( + result.attrs.containsKey("list[" + (DDEvaluator.MAX_LIST_ELEMENTS - 1) + "]"), + equalTo(true)); + assertThat( + result.attrs.containsKey("list[" + DDEvaluator.MAX_LIST_ELEMENTS + "]"), equalTo(false)); + assertThat(result.truncatedReason, equalTo("max_list_elements")); + } + + @Test + public void testCopyPrunedContextCapsStructureWidth() { + final dev.openfeature.sdk.MutableStructure wide = new dev.openfeature.sdk.MutableStructure(); + for (int i = 0; i < DDEvaluator.MAX_STRUCTURE_PROPERTIES + 50; i++) { + wide.add(String.format("p%04d", i), "v"); + } + final EvaluationContext context = new MutableContext().add("struct", wide); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + long structKeys = result.attrs.keySet().stream().filter(k -> k.startsWith("struct.")).count(); + assertThat(structKeys, equalTo((long) DDEvaluator.MAX_STRUCTURE_PROPERTIES)); + assertThat(result.truncatedReason, equalTo("max_structure_properties")); + } + + @Test + public void testCopyPrunedContextTruncatesDeepNesting() { + Value nested = new Value("leaf"); + for (int i = 0; i < 10_000; i++) { + nested = new Value(singletonList(nested)); + } + final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + final StringBuilder truncatedKey = new StringBuilder("deep"); + for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { + truncatedKey.append("[0]"); + } + // The recursion stops on the first list element at MAX_SNAPSHOT_DEPTH; deeper elements are + // never walked and no entry is emitted for them. + assertThat(result.attrs.containsKey(truncatedKey.toString()), equalTo(false)); + assertThat(result.attrs.size(), equalTo(0)); + assertThat(result.truncatedReason, equalTo("max_snapshot_depth")); + } + + @Test + public void testCopyPrunedContextExcludesTargetingKey() { + final MutableContext context = new MutableContext("user-42").add("region", "us-east-1"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("region", "us-east-1")); + assertThat(result.attrs.containsKey("targetingKey"), equalTo(false)); + assertThat(result.truncatedReason, equalTo(null)); + } + + @Test + public void testCopyPrunedContextNoTruncationReturnsNullReason() { + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + context.add("tier", "gold"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("region", "us-east-1")); + assertThat(result.truncatedReason, equalTo(null)); + } + + @Test + public void testCopyPrunedContextMultipleReasonsAreSortedAndDeduplicated() { + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; + java.util.Arrays.fill(longKeyChars, 'k'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add("dropValue", new String(longChars)); + context.add(new String(longKeyChars), "dropKey"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + // Both max_key_length and max_value_length fired; sorted alphabetically, no duplicates. + assertThat(result.truncatedReason, equalTo("max_key_length,max_value_length")); + } + + private static Arguments[] typeCompatibilityTestCases() { + return new Arguments[] { + Arguments.of(Boolean.class, ValueType.BOOLEAN, true), + Arguments.of(String.class, ValueType.BOOLEAN, false), + Arguments.of(String.class, ValueType.STRING, true), + Arguments.of(Boolean.class, ValueType.STRING, false), + Arguments.of(Integer.class, ValueType.INTEGER, true), + Arguments.of(String.class, ValueType.INTEGER, false), + Arguments.of(Double.class, ValueType.NUMERIC, true), + Arguments.of(String.class, ValueType.NUMERIC, false), + Arguments.of(Value.class, ValueType.JSON, true), + Arguments.of(String.class, ValueType.JSON, false), + Arguments.of(String.class, null, true), + }; + } + + @ParameterizedTest + @MethodSource("typeCompatibilityTestCases") + public void testTypeCompatibility( + final Class target, final ValueType variationType, final boolean expected) { + assertThat(DDEvaluator.isTypeCompatible(target, variationType), equalTo(expected)); + } + + @Test + public void testContextCopyDetectsContainerCycles() { + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + final EvaluationContext listContext = mock(EvaluationContext.class); + when(listContext.keySet()).thenReturn(java.util.Collections.singleton("list")); + when(listContext.getValue("list")).thenReturn(listValue); + + final DDEvaluator.CopyResult listResult = DDEvaluator.copyPrunedContext(listContext); + assertThat(listResult.truncatedReason, equalTo("cycle")); + + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + final EvaluationContext structureContext = mock(EvaluationContext.class); + when(structureContext.keySet()).thenReturn(java.util.Collections.singleton("structure")); + when(structureContext.getValue("structure")).thenReturn(structureValue); + + final DDEvaluator.CopyResult structureResult = DDEvaluator.copyPrunedContext(structureContext); + assertThat(structureResult.truncatedReason, equalTo("cycle")); + } + + @Test + public void testCopyPrunedContextHandlesEmptyAndScalarValues() { + assertThat(DDEvaluator.copyPrunedContext(null).attrs, equalTo(emptyMap())); + assertThat(DDEvaluator.copyPrunedContext(new MutableContext()).attrs, equalTo(emptyMap())); + + final EvaluationContext nullContext = mock(EvaluationContext.class); + when(nullContext.keySet()) + .thenReturn(new java.util.LinkedHashSet<>(asList("java-null", "openfeature-null"))); + when(nullContext.getValue("java-null")).thenReturn(null); + when(nullContext.getValue("openfeature-null")).thenReturn(new Value()); + final Map snapshot = DDEvaluator.snapshotValues(nullContext); + assertNull(snapshot.get("java-null")); + assertThat(snapshot.get("openfeature-null").isNull(), equalTo(true)); + final DDEvaluator.CopyResult nullResult = DDEvaluator.copyPrunedContext(nullContext); + assertThat(nullResult.attrs, hasEntry("java-null", null)); + assertThat(nullResult.attrs, hasEntry("openfeature-null", null)); + + final MutableContext scalarContext = new MutableContext(); + scalarContext.add("boolean", true); + scalarContext.add("number", 42); + scalarContext.add("instant", Instant.parse("2026-08-20T00:00:00Z")); + final DDEvaluator.CopyResult scalarResult = DDEvaluator.copyPrunedContext(scalarContext); + assertThat(scalarResult.attrs, hasEntry("boolean", true)); + assertThat(scalarResult.attrs, hasEntry("number", 42)); + assertThat(scalarResult.attrs, hasEntry("instant", "2026-08-20T00:00:00Z")); + } + + @Test + public void testFlattenValuesHandlesJavaNullAndContainerCycles() { + final Map values = new HashMap<>(); + values.put("null", null); + + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + values.put("list", listValue); + + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + values.put("structure", structureValue); + + final Map flattened = DDEvaluator.flattenValues(values); + assertThat(flattened, hasEntry("null", null)); + assertThat(flattened.size(), equalTo(1)); + } + + private static Map mapOf(final Object... props) { + final Map result = new HashMap<>(props.length << 1); + int index = 0; + while (index < props.length) { + final String key = String.valueOf(props[index++]); + final Object value = props[index++]; + result.put(key, value); + } + return result; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java new file mode 100644 index 00000000000..32eb936c932 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java @@ -0,0 +1,648 @@ +package com.datadog.openfeature; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.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 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; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link FlagEvalLoggingHook}: context capture, non-blocking enqueue, eval-time + * metadata, absent-variant detection, and killswitch-via-writer-null behaviour. + */ +class FlagEvalLoggingHookTest { + + // ---- helpers ---- + + /** 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); + } + + private static FlagEvaluationDetails details( + final String flagKey, + final Object value, + final String variant, + final String reason, + final ImmutableMetadata metadata) { + final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = + FlagEvaluationDetails.builder().flagKey(flagKey).value(value).reason(reason); + if (variant != null) { + builder.variant(variant); + } + if (metadata != null) { + builder.flagMetadata(metadata); + } + return builder.build(); + } + + private static HookContext hookCtxWithTargetingKey( + final String flagKey, final String targetingKey) { + final MutableContext ctx = new MutableContext(targetingKey); + return HookContext.builder() + .flagKey(flagKey) + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + } + + // ---- test: hook calls writer.enqueue once with flagKey, variant, allocationKey ---- + + @Test + void finallyAfterEnqueuesEventWithAllBasicFields() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + details( + "my-flag", + "on-value", + "on", + Reason.TARGETING_MATCH.name(), + ImmutableMetadata.builder().addString("allocationKey", "alloc-1").build()); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get(), "writer.enqueue must be called once"); + final FlagEvalEvent e = captured.get(); + assertEquals("my-flag", e.flagKey); + assertEquals("on", e.variant, "variant must be the OpenFeature variant key"); + assertEquals("alloc-1", e.allocationKey); + } + + // ---- variant comes from details.getVariant(), NOT details.getValue() ---- + + @Test + void variantIsTheVariantKeyNotTheEvaluatedValue() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + // value and variant DIFFER, so a value-vs-variant mistake is detectable. + final FlagEvaluationDetails det = + details( + "g1-flag", + "the-evaluated-value", // value + "the-variant-key", // variant + Reason.TARGETING_MATCH.name(), + null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "the-variant-key", + captured.get().variant, + "variant must be sourced from details.getVariant(), not details.getValue()"); + } + + // ---- test: evalTimeMs from metadata "__dd_eval_timestamp_ms" ---- + + @Test + void evalTimeMsComesFromMetadataWhenPresent() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final long expectedTimestamp = 1_700_000_000_000L; + final FlagEvaluationDetails det = + details( + "ts-flag", + "v", + "v", + Reason.SPLIT.name(), + ImmutableMetadata.builder() + .addString("allocationKey", "a") + .addLong("__dd_eval_timestamp_ms", expectedTimestamp) + .build()); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + expectedTimestamp, + captured.get().evalTimeMs, + "evalTimeMs must come from __dd_eval_timestamp_ms metadata when present"); + } + + // ---- test: evalTimeMs falls back to System.currentTimeMillis() when absent ---- + + @Test + void evalTimeMsFallsBackToCurrentTimeWhenMetadataAbsent() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final long before = System.currentTimeMillis(); + final FlagEvaluationDetails det = + details("ts-flag", "v", "v", Reason.SPLIT.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + final long after = System.currentTimeMillis(); + assertNotNull(captured.get()); + final long ts = captured.get().evalTimeMs; + assertTrue( + ts >= before && ts <= after, + "evalTimeMs must fall back to hook-fire time when metadata absent. got: " + ts); + } + + // ---- test: absent variant -> variant is null -> runtime default ---- + + @Test + void absentVariantProducesNullVariant() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + // A runtime default returns the default value but no variant. + final FlagEvaluationDetails det = + details("def-flag", "default-value", null, Reason.DEFAULT.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().variant, "Absent variant must stay null (runtime default)"); + } + + // ---- test: error message captured from details (error object support) ---- + + @Test + void errorMessageCapturedFromDetailsUnderConsentOn() { + // With observeFullEvaluationData=true the provider's raw message is preserved verbatim so + // operators keep the diagnostic detail they opted in to. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage("value does not match declared type") + .flagMetadata(consentOnMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "value does not match declared type", + captured.get().errorMessage, + "errorMessage must be captured from the evaluation details under consent-on"); + } + + @Test + void errorMessageReplacedByErrorCodeUnderConsentOff() { + // Defense-in-depth: even if a provider hands us a raw message under consent-off (a bug in the + // provider, or a third-party provider that doesn't distinguish consent tiers), the hook must + // substitute the ErrorCode name so raw PII from exception messages never reaches the wire. + // Uses a PII-looking marker exactly like the wire-level guards in FlagEvaluationWriterImplTest. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage("For input string: \"jane.doe@datadoghq.com\"") + .flagMetadata(consentOffMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "TYPE_MISMATCH", + captured.get().errorMessage, + "consent-off must replace the raw message with the ErrorCode name"); + assertFalse( + captured.get().errorMessage.contains("jane.doe@datadoghq.com"), + "raw PII must never survive into the enqueued event under consent-off"); + } + + @Test + void errorMessageDroppedWhenConsentOffAndNoErrorCode() { + // Edge case: no ErrorCode available (unusual — providers should set one for ERROR reason). + // Consent-off drops the message and there's nothing to substitute, so the enqueued event has + // no error message at all. This is the strictest privacy-preserving outcome. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorMessage("For input string: \"jane.doe@datadoghq.com\"") + .flagMetadata(consentOffMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().errorMessage); + } + + // ---- test: error code used as fallback message when error message is empty ---- + + @Test + void errorCodeUsedAsFallbackWhenMessageEmpty() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "FLAG_NOT_FOUND", + captured.get().errorMessage, + "error code name must be used when no error message is present"); + } + + // ---- test: success path has no error message ---- + + @Test + void successPathHasNullErrorMessage() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + details("ok-flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().errorMessage, "success path must have no error message"); + } + + // ---- test: hook does NO aggregation on the hook thread ---- + + @Test + void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final FlagEvaluationDetails det = + details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + // Capacity check gates the enqueue; exactly one enqueue call; nothing else. + verify(writer, times(1)).hasCapacityForEnqueue(); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + verifyNoMoreInteractions(writer); + } + + @Test + void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(false); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final FlagEvaluationDetails det = + details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + // Pre-queue guard fires: one capacity check, one drop count, and no enqueue at all. + verify(writer, times(1)).hasCapacityForEnqueue(); + verify(writer, times(1)).countPreQueueOverflow(); + verify(writer, never()).enqueue(any(FlagEvalEvent.class)); + } + + @Test + void countContextTruncatedCalledWhenContextIsTruncated() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + // Context with one oversized string value — triggers max_value_length. + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final MutableContext ctx = new MutableContext("user-1"); + ctx.add("oversized", new String(longChars)); + final HookContext hookCtx = + HookContext.builder() + .flagKey("flag") + .type(dev.openfeature.sdk.FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + final FlagEvaluationDetails det = + details( + "flag", + "v", + "v", + dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), + consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + verify(writer, times(1)).countContextTruncated("max_value_length"); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + } + + @Test + void countContextTruncatedNotCalledWhenNoTruncation() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final MutableContext ctx = new MutableContext("user-1"); + ctx.add("region", "us-east-1"); + final HookContext hookCtx = + HookContext.builder() + .flagKey("flag") + .type(dev.openfeature.sdk.FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + final FlagEvaluationDetails det = + details( + "flag", + "v", + "v", + dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), + consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + verify(writer, never()).countContextTruncated(any()); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + } + + // ---- test: writer=null -> no-op (killswitch off / not yet started) ---- + + @Test + void writerNullIsNoOp() { + final FlagEvalLoggingHook hook = hookWithWriter(null); + final FlagEvaluationDetails det = + details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + // Must not throw; nothing is enqueued + hook.finallyAfter(null, det, Collections.emptyMap()); + } + + @Test + void writerLookupLinkageErrorIsNoOp() { + final FlagEvalLoggingHook hook = + new FlagEvalLoggingHook<>( + () -> { + throw new NoSuchMethodError("old bootstrap"); + }); + final FlagEvaluationDetails det = + details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + assertDoesNotThrow(() -> hook.finallyAfter(null, det, Collections.emptyMap())); + } + + // ---- test: details=null -> no-op ---- + + @Test + void detailsNullIsNoOp() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + // Should not throw + hook.finallyAfter(null, null, Collections.emptyMap()); + + verifyNoInteractions(writer); + } + + // ---- test: targetingKey extracted from evaluation context ---- + + @Test + void targetingKeyExtractedFromContext() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + details("ctx-flag", "v", "v", Reason.SPLIT.name(), null); + + final HookContext hookCtx = hookCtxWithTargetingKey("ctx-flag", "user-42"); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "user-42", + captured.get().targetingKey, + "targetingKey must be extracted from the evaluation context"); + } + + @Test + void contextAttributesAreFlattenedAndConvertedInline() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final Map profile = new HashMap<>(); + profile.put("tier", "gold"); + final Map attributes = new HashMap<>(); + attributes.put("score", 42); + attributes.put("profile", profile); + final MutableContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + context.setTargetingKey("user-42"); + + final HookContext hookCtx = + HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final FlagEvaluationDetails det = + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + // Flatten now happens inline in DDEvaluator#copyPrunedContext on the hot path, so the event + // arrives with attrs already populated (no deferred supplier). + final Map attrs = captured.get().attrs; + assertFalse(attrs.isEmpty(), "hook must flatten context inline"); + assertEquals(42, attrs.get("score")); + assertEquals("gold", attrs.get("profile.tier")); + assertFalse(attrs.containsKey("targetingKey")); + assertTrue( + attrs.values().stream().noneMatch(Value.class::isInstance), + "context attrs must contain converted scalar values, not OpenFeature Value wrappers"); + } + + @Test + void contextAttributesUseEnqueueTimeSnapshot() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final MutableContext context = new MutableContext("user-42"); + context.add("region", "us-east-1"); + final MutableStructure profile = new MutableStructure(); + profile.add("tier", "gold"); + context.add("profile", profile); + final List cohorts = new ArrayList<>(); + cohorts.add(Value.objectToValue("beta")); + context.add("cohorts", cohorts); + + final HookContext hookCtx = + HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final FlagEvaluationDetails det = + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + context.add("region", "eu-west-1"); + context.add("late", "ignored"); + profile.add("tier", "platinum"); + profile.add("late", "ignored"); + cohorts.set(0, Value.objectToValue("ga")); + cohorts.add(Value.objectToValue("late")); + + assertNotNull(captured.get()); + final Map attrs = captured.get().attrs; + assertEquals("us-east-1", attrs.get("region")); + assertEquals("gold", attrs.get("profile.tier")); + assertEquals("beta", attrs.get("cohorts[0]")); + assertFalse(attrs.containsKey("late")); + assertFalse(attrs.containsKey("profile.late")); + assertFalse(attrs.containsKey("cohorts[1]")); + } + + // ---- observeFullEvaluationData is read from evaluation metadata, never the gateway ---- + + @Test + void readsObserveFullEvaluationDataTrueFromEvaluationMetadata() { + assertTrue(enqueuedEventWithConsentMetadata(true).observeFullEvaluationData); + } + + @Test + void readsObserveFullEvaluationDataFalseFromEvaluationMetadata() { + assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); + } + + @Test + void observeFullEvaluationDataDefaultsToFalseWhenMetadataAbsent() { + // No metadata at all: fail-closed toward privacy. + assertFalse(enqueuedEventWithConsentMetadata(null).observeFullEvaluationData); + } + + @Test + void protectedPathSkipsEvaluationContextCapture() { + // Consent off → the hook must not snapshot the evaluation context at all. Verified by mutating + // the context after finallyAfter returns and asserting the enqueued event still sees nothing. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + + final HookContext hookCtx = + HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final ImmutableMetadata consentOff = + ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) + .build(); + + hook.finallyAfter( + hookCtx, + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOff), + Collections.emptyMap()); + context.add("region", "eu-west-1"); + + assertNotNull(captured.get()); + assertTrue(captured.get().attrs.isEmpty()); + } + + /** + * Fires the hook once for a simple targeted evaluation whose metadata carries the given consent + * value ({@code null} = key absent) and returns the enqueued event. + */ + private FlagEvalEvent enqueuedEventWithConsentMetadata(final Boolean consent) { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + final ImmutableMetadata metadata = + consent == null + ? null + : ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, consent) + .build(); + hook.finallyAfter( + hookCtxWithTargetingKey("obs-flag", "user-1"), + details("obs-flag", "on", "on", Reason.TARGETING_MATCH.name(), metadata), + Collections.emptyMap()); + assertNotNull(captured.get(), "writer.enqueue must be called once"); + return captured.get(); + } + + private static ImmutableMetadata consentOnMetadata() { + return ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) + .build(); + } + + private static ImmutableMetadata consentOffMetadata() { + return ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) + .build(); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java new file mode 100644 index 00000000000..cb56826fbb0 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java @@ -0,0 +1,126 @@ +package com.datadog.openfeature; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Reason; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class FlagEvalMetricsHookTest { + + @Test + void finallyAfterRecordsBasicEvaluation() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value("on-value") + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata( + ImmutableMetadata.builder().addString("allocationKey", "default-alloc").build()) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record( + eq("my-flag"), + eq("on"), + eq(Reason.TARGETING_MATCH.name()), + isNull(), + eq("default-alloc")); + } + + @Test + void finallyAfterRecordsErrorEvaluation() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("missing-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record( + eq("missing-flag"), + isNull(), + eq(Reason.ERROR.name()), + eq(ErrorCode.FLAG_NOT_FOUND), + isNull()); + } + + @Test + void finallyAfterHandlesNullFlagMetadata() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value(true) + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull()); + } + + @Test + void finallyAfterHandlesNullVariantAndReason() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder().flagKey("my-flag").value("default").build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull()); + } + + @Test + void finallyAfterNeverThrows() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + // Should not throw even with completely null inputs + hook.finallyAfter(null, null, null); + + verifyNoInteractions(metrics); + } + + @Test + void finallyAfterIsNoOpWhenMetricsIsNull() { + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(null); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value(true) + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + + // Should not throw + hook.finallyAfter(null, details, Collections.emptyMap()); + } +} diff --git a/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/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java new file mode 100644 index 00000000000..61e0ab4dd4b --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java @@ -0,0 +1,165 @@ +package com.datadog.openfeature; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.openfeature.sdk.ErrorCode; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class FlagEvalMetricsTest { + + @Test + void recordBasicAttributes() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.key", "my-flag"); + assertAttribute(attrs, "feature_flag.result.variant", "on"); + assertAttribute(attrs, "feature_flag.result.reason", "targeting_match"); + assertNoAttribute(attrs, "error.type"); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordErrorAttributes() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("missing-flag", "", "ERROR", ErrorCode.FLAG_NOT_FOUND, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.key", "missing-flag"); + assertAttribute(attrs, "feature_flag.result.variant", ""); + assertAttribute(attrs, "feature_flag.result.reason", "error"); + assertAttribute(attrs, "error.type", "flag_not_found"); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordTypeMismatchError() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "", "ERROR", ErrorCode.TYPE_MISMATCH, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "error.type", "type_mismatch"); + } + + @Test + void recordWithAllocationKey() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, "default-allocation"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.allocation_key", "default-allocation"); + } + + @Test + void recordOmitsEmptyAllocationKey() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, ""); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordNullVariantBecomesEmptyString() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", null, "DEFAULT", null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.variant", ""); + } + + @Test + void recordNullReasonBecomesUnknown() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", null, null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.reason", "unknown"); + } + + @Test + void recordIsNoOpWhenCounterIsNull() { + FlagEvalMetrics metrics = new FlagEvalMetrics((LongCounter) null); + // Should not throw + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + } + + @Test + void shutdownClearsCounter() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.shutdown(); + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + + verifyNoInteractions(counter); + } + + @Test + void defaultConstructorUsesOpenTelemetryApiOnly() { + try (FlagEvalMetrics metrics = new FlagEvalMetrics()) { + metrics.record("count-flag", "on", "STATIC", null, "default-alloc"); + } + } + + private static void assertAttribute(Attributes attrs, String key, String expected) { + String value = + attrs.asMap().entrySet().stream() + .filter(e -> e.getKey().getKey().equals(key)) + .map(e -> e.getValue().toString()) + .findFirst() + .orElse(null); + if (!expected.equals(value)) { + throw new AssertionError("Expected attribute " + key + "=" + expected + " but got " + value); + } + } + + private static void assertNoAttribute(Attributes attrs, String key) { + boolean present = attrs.asMap().keySet().stream().anyMatch(k -> k.getKey().equals(key)); + if (present) { + throw new AssertionError("Expected no attribute " + key + " but it was present"); + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java new file mode 100644 index 00000000000..4008aac4974 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java @@ -0,0 +1,600 @@ +package com.datadog.openfeature; + +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; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import 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; +import dev.openfeature.sdk.EventDetails; +import dev.openfeature.sdk.Features; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.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; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class ProviderTest { + + private static final long EVENT_TIMEOUT_SECONDS = 10; + + private ExecutorService executor; + private LocalHttpServer cdn; + private DDEvaluator evaluator; + + @BeforeEach + public void setup() throws IOException { + this.executor = Executors.newSingleThreadExecutor(); + this.cdn = new LocalHttpServer(); + } + + @AfterEach + public void tearDown() { + this.executor.shutdownNow(); + OpenFeatureAPI.getInstance().shutdown(); + 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 + public void testSetProvider() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final CompletableFuture readyEvent = new CompletableFuture<>(); + api.onProviderReady(readyEvent::complete); + api.setProvider(newProvider()); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); + + dispatch(emptyConfiguration()); + readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWait() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + 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)); + + dispatch(emptyConfiguration()); + provider.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() throws Exception { + final CompletableFuture readyEvent = new CompletableFuture<>(); + final Consumer readyEventHandler = completingHandler(readyEvent); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final Client client = api.getClient(); + client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); + + assertThrows( + ProviderNotReadyError.class, + () -> api.setProviderAndWait(newProvider(new Options().initTimeout(10, MILLISECONDS)))); + + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + assertFalse(readyEvent.isDone()); + + dispatch(emptyConfiguration()); + + final EventDetails eventDetails = readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + assertThat(eventDetails.getProviderName(), equalTo(METADATA)); + verify(readyEventHandler, times(1)).accept(any()); + } + + @Test + public void testSetProviderAndWaitCompletesWhenConfigurationArrivesAtTimeoutBoundary() + throws Exception { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = + new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, + final java.util.concurrent.TimeUnit timeUnit, + final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + return false; + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + }; + + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + api.setProviderAndWait(providerRef[0]); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWaitFailsWhenConfigurationIsRemovedBeforeInitializationCompletes() { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = + new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, + final java.util.concurrent.TimeUnit timeUnit, + final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + hasConfiguration = false; + providerRef[0].onConfigurationChange(); + return true; + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + }; + + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + + assertThrows(ProviderNotReadyError.class, () -> api.setProviderAndWait(providerRef[0])); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + } + + @Test + public void testInitializationErrorDoesNotOverwriteRecoveredReadyState() throws Exception { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = + new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, + final java.util.concurrent.TimeUnit timeUnit, + final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + hasConfiguration = false; + providerRef[0].onConfigurationChange(); + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + throw new ProviderNotReadyError( + "Provider timed-out while waiting for initial configuration"); + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + }; + + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + + assertThrows(ProviderNotReadyError.class, () -> providerRef[0].initialize(null)); + + assertThat(initializationState(providerRef[0]), equalTo("READY")); + } + + @Test + public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + 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)); + + final CompletableFuture errorEvent = new CompletableFuture<>(); + final CompletableFuture readyEvent = new CompletableFuture<>(); + final CompletableFuture configChangedEvent = new CompletableFuture<>(); + final Consumer errorEventHandler = completingHandler(errorEvent); + final Consumer readyEventHandler = completingHandler(readyEvent); + final Consumer configChangedEventHandler = completingHandler(configChangedEvent); + client.on(ProviderEvent.PROVIDER_ERROR, errorEventHandler); + client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEventHandler); + + dispatch(null); + final EventDetails eventDetails = errorEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + assertThat(eventDetails.getProviderName(), equalTo(METADATA)); + + final FlagEvaluationDetails evalDetails = client.getStringDetails("missing", "default"); + assertThat(evalDetails.getValue(), equalTo("default")); + assertThat(evalDetails.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + + client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); + dispatch(emptyConfiguration()); + readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + + dispatch(emptyConfiguration()); + configChangedEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + verify(errorEventHandler, times(1)).accept(any()); + verify(readyEventHandler, times(1)).accept(any()); + verify(configChangedEventHandler, times(1)).accept(any()); + } + + @Test + public void 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")); + } + + @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, () -> OpenFeatureAPI.getInstance().setProviderAndWait(providerRef[0])); + } + + @Test + public void testGetProviderHooksReturnsFlagEvalMetricsHook() { + Provider provider = + new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); + List hooks = provider.getProviderHooks(); + // Two hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) + assertThat(hooks.size(), equalTo(2)); + assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); + assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); + } + + @Test + public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Exception { + 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(); + 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 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 + public void testGetProviderHooksReturnsFlagEvalMetricsHookWithAndWithoutSpanEnrichment() { + final Evaluator evaluator = mock(Evaluator.class); + final Provider providerWithoutSpanEnrichment = + new Provider(new Options(), evaluator, Boolean.FALSE); + final Provider providerWithSpanEnrichment = + new Provider(new Options(), evaluator, Boolean.TRUE); + + assertHasFlagEvalMetricsHook(providerWithoutSpanEnrichment); + assertHasFlagEvalMetricsHook(providerWithSpanEnrichment); + } + + @Test + public void testShutdownCleansUpEvaluator() throws Exception { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + final Provider provider = + new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator, Boolean.FALSE); + + provider.initialize(null); + provider.shutdown(); + + verify(evaluator).shutdown(); + // After shutdown, getProviderHooks still returns a list with both OTel + logging hooks + assertThat(provider.getProviderHooks().size(), equalTo(2)); + } + + private static void assertHasFlagEvalMetricsHook(final Provider provider) { + assertTrue( + provider.getProviderHooks().stream().anyMatch(FlagEvalMetricsHook.class::isInstance), + "flag evaluation metrics hook should be registered"); + } + + public interface EvaluateMethod { + FlagEvaluationDetails evaluate(Features client, String flag, E defaultValue); + } + + private static Arguments[] providerMethods() { + return new Arguments[] { + Arguments.of("bool", false, (EvaluateMethod) Features::getBooleanDetails), + Arguments.of("string", "Hello!", (EvaluateMethod) Features::getStringDetails), + Arguments.of("int", 23, (EvaluateMethod) Features::getIntegerDetails), + Arguments.of("double", 3.14D, (EvaluateMethod) Features::getDoubleDetails), + Arguments.of("object", new Value(), (EvaluateMethod) Features::getObjectDetails) + }; + } + + @MethodSource("providerMethods") + @ParameterizedTest + public void testProviderEvaluation( + final String flag, final E defaultValue, final EvaluateMethod method) throws Exception { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + when(evaluator.evaluate(any(), any(), any(), any())) + .thenAnswer( + invocation -> + ProviderEvaluation.builder() + .value(invocation.getArgument(2)) + .reason("MOCK") + .build()); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); + final Client client = api.getClient(); + final FlagEvaluationDetails result = method.evaluate(client, flag, defaultValue); + assertThat(result.getValue(), equalTo(defaultValue)); + assertThat(result.getReason(), equalTo("MOCK")); + verify(evaluator, times(1)).initialize(eq(10L), eq(SECONDS), any()); + verify(evaluator, times(1)) + .evaluate(any(), eq(flag), eq(defaultValue), any(EvaluationContext.class)); + } + + private static String initializationState(final Provider provider) throws Exception { + final Field stateField = Provider.class.getDeclaredField("initializationState"); + stateField.setAccessible(true); + final AtomicReference state = (AtomicReference) stateField.get(provider); + return state.get().toString(); + } + + @SuppressWarnings("unchecked") + private static Consumer completingHandler( + final CompletableFuture event) { + final Consumer handler = mock(Consumer.class); + doAnswer( + invocation -> { + event.complete(invocation.getArgument(0)); + return null; + }) + .when(handler) + .accept(any()); + return handler; + } + + /** 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"); + } + + 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)); + } + + 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); + } + + @SuppressWarnings("unchecked") + private static Map firstFlagEvaluation(final byte[] payload) { + final Map request = JsonReading.readObject(payload); + return (Map) ((List) request.get("flagEvaluations")).get(0); + } + + /** 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/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java new file mode 100644 index 00000000000..e9f261e9ad3 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java @@ -0,0 +1,289 @@ +package com.datadog.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.connector.SpanEnricher; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * 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 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 ---- + + private static FlagEvaluationDetails details( + final String flagKey, + final String variant, + final Object value, + final ImmutableMetadata metadata) { + return FlagEvaluationDetails.builder() + .flagKey(flagKey) + .variant(variant) + .value(value) + .flagMetadata(metadata) + .build(); + } + + private static ImmutableMetadata metadata(final Integer serialId, final boolean doLog) { + final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + if (serialId != null) { + builder.addInteger(SpanEnrichmentHook.METADATA_SERIAL_ID, serialId); + } + builder.addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, doLog); + return builder.build(); + } + + private static HookContext ctx(final String flagKey, final String targetingKey) { + return HookContext.from( + flagKey, + FlagValueType.STRING, + null, + null, + targetingKey == null ? new ImmutableContext() : new ImmutableContext(targetingKey), + "default"); + } + + // ---- serial-id branch ---- + + @Test + void serialIdWithDoLogDispatchesSerialAndSubject() { + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("flag", "user-1"), + details("flag", "on", "v", metadata(42, true)), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertTrue(event.hasSerialId()); + assertEquals(42, event.serialId()); + assertTrue(event.doLog()); + assertEquals("user-1", event.targetingKey()); + } + + @Test + void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("flag", "user-1"), + details("flag", "on", "v", metadata(7, false)), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertTrue(event.hasSerialId()); + assertEquals(7, event.serialId()); + assertFalse( + event.doLog(), "doLog=false must be carried through so the write side skips subject"); + } + + @Test + void wrongTypedSerialIdDispatchesNothing() { + // Defensive: a non-integer value under the serial-id key (wrong type) is ignored, not crashed. + final ImmutableMetadata bad = + ImmutableMetadata.builder() + .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") + .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) + .build(); + new SpanEnrichmentHook(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"); + } + + // ---- runtime-default branch (missing variant) ---- + + @Test + void missingVariantDispatchesRuntimeDefaultWithNativeMap() { + final Map objectValue = Collections.singletonMap("k", "val"); + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("obj-flag", "user-1"), + details("obj-flag", null, objectValue, ImmutableMetadata.builder().build()), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertFalse(event.hasSerialId()); + assertEquals("obj-flag", event.flagKey()); + assertEquals( + objectValue, event.defaultValue(), "a native map default passes through unchanged"); + } + + @Test + void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { + final Map inner = new LinkedHashMap<>(); + inner.put("enabled", new Value(true)); + final Value structureDefault = new Value(new ImmutableStructure(inner)); + + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("struct-flag", "user-1"), + details("struct-flag", null, structureDefault, ImmutableMetadata.builder().build()), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final Object value = captured.get(0).defaultValue(); + // Crucially a native Map, NOT an OpenFeature Value — the seam carries only JDK types. + final Map asMap = assertInstanceOf(Map.class, value); + assertEquals(Boolean.TRUE, asMap.get("enabled")); + } + + // ---- unwrapDefaultValue (the Value -> native conversion) ---- + + @Test + void unwrapConvertsValueScalarsToNative() { + assertEquals("hello", SpanEnrichmentHook.unwrapDefaultValue(new Value("hello"))); + assertEquals(Boolean.TRUE, SpanEnrichmentHook.unwrapDefaultValue(new Value(true))); + assertEquals(7, SpanEnrichmentHook.unwrapDefaultValue(new Value(7))); + assertNull(SpanEnrichmentHook.unwrapDefaultValue(new Value())); + } + + @Test + void unwrapConvertsValueListToNativeList() { + final Value listDefault = + new Value(Arrays.asList(new Value("a"), new Value(2), new Value(true))); + final Object out = SpanEnrichmentHook.unwrapDefaultValue(listDefault); + assertEquals(Arrays.asList("a", 2, Boolean.TRUE), out); + } + + @Test + void unwrapPassesNativeValuesThrough() { + final Map native0 = Collections.singletonMap("a", "b"); + assertEquals(native0, SpanEnrichmentHook.unwrapDefaultValue(native0)); + assertEquals("plain", SpanEnrichmentHook.unwrapDefaultValue("plain")); + assertNull(SpanEnrichmentHook.unwrapDefaultValue(null)); + } + + // ---- error isolation ---- + + @Test + void nullDetailsDispatchesNothing() { + new SpanEnrichmentHook(this.enricher).finallyAfter(null, null, null); + assertTrue(captured.isEmpty()); + } + + // ---- Provider gating ---- + + @Test + void gateOffConstructsNoHook() { + final Provider provider = new Provider(new Provider.Options(), null, Boolean.FALSE); + assertNull(provider.spanEnrichmentHook(), "gate off => no span-enrichment hook"); + for (final Hook hook : provider.getProviderHooks()) { + assertFalse( + hook instanceof SpanEnrichmentHook, "gate off => SpanEnrichmentHook never registered"); + } + } + + @Test + void gateOnConstructsHookAndRegistersItInProviderHooks() { + final Provider provider = new Provider(new Provider.Options(), null, Boolean.TRUE); + assertTrue(provider.spanEnrichmentHook() != null, "gate on => hook constructed"); + boolean registered = false; + for (final Hook hook : provider.getProviderHooks()) { + if (hook instanceof SpanEnrichmentHook) { + registered = true; + } + } + assertTrue(registered, "gate on => SpanEnrichmentHook registered in getProviderHooks"); + } + + @Test + void getProviderHooksReturnsSameInstanceEachCall() { + final Provider gateOff = new Provider(new Provider.Options(), null, Boolean.FALSE); + assertTrue( + gateOff.getProviderHooks() == gateOff.getProviderHooks(), + "gate off => getProviderHooks allocates nothing (same instance)"); + + final Provider gateOn = new Provider(new Provider.Options(), null, Boolean.TRUE); + assertTrue( + gateOn.getProviderHooks() == gateOn.getProviderHooks(), + "gate on => getProviderHooks allocates nothing (same instance)"); + } + + /** 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/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; + } + } +} From e9664bbb86d5cd3562a2da9a33f2eb36e4ec425f Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:01:46 +0200 Subject: [PATCH 16/22] refactor(ffe): Pass evaluator metadata through to OpenFeature The evaluator now builds OpenFeature ImmutableMetadata directly, so the provider no longer copies it on every evaluation. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/datadog/openfeature/DDEvaluator.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index 08af9e7e571..bf498809a27 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -182,14 +182,6 @@ public Object attribute(final String 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 = @@ -202,7 +194,7 @@ public Object attribute(final String name) { ? null : ErrorCode.valueOf(result.getErrorCode().name())) .errorMessage(result.getErrorMessage()) - .flagMetadata(metadata.build()) + .flagMetadata(result.getFlagMetadata()) .build(); if (result.isLogExposure()) { dispatchExposure(key, evaluation, result.getSerialId(), context); From cf9f18ffae4968b00f86c1054b2b2e24825749ad Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:25:04 +0200 Subject: [PATCH 17/22] refactor(ffe): Delegate evaluation to the OpenFeature-typed evaluator The evaluator now takes the OpenFeature context and returns the ProviderEvaluation, so the provider no longer adapts the context and converts the result on every evaluation. Exposures are dispatched through the evaluator ExposureListener. Co-Authored-By: Claude Opus 5.5 --- .../com/datadog/openfeature/DDEvaluator.java | 61 ++----------------- 1 file changed, 6 insertions(+), 55 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index bf498809a27..f1ae8d1b40a 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -6,14 +6,12 @@ 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; @@ -106,7 +104,7 @@ class DDEvaluator implements Evaluator, Consumer { 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()); + this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::dispatchExposure); } @Override @@ -155,55 +153,12 @@ public void accept(final ServerConfiguration config) { } @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 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(result.getFlagMetadata()) - .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; + return core.evaluate(configuration.get(), target, key, defaultValue, context); } static boolean isAllocationActive(final Allocation allocation, final Instant now) { @@ -211,20 +166,16 @@ static boolean isAllocationActive(final Allocation allocation, final Instant now } static boolean isTypeCompatible(final Class target, final ValueType type) { - return FlagEvaluator.isTypeCompatible(coreType(target), type); + return FlagEvaluator.isTypeCompatible(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( + private void dispatchExposure( final String flag, - final ProviderEvaluation evaluation, + final ProviderEvaluation evaluation, final Integer serialId, final EvaluationContext context) { final String allocationKey = allocationKey(evaluation); @@ -255,7 +206,7 @@ void recordExposure(final ExposureEvent event) { } } - private static String allocationKey(final ProviderEvaluation resolution) { + private static String allocationKey(final ProviderEvaluation resolution) { final ImmutableMetadata meta = resolution.getFlagMetadata(); return meta == null ? null : meta.getString("allocationKey"); } From f41a9d376ceebaac3b95441f42d2f15b0ba1b17b Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 16:09:29 +0200 Subject: [PATCH 18/22] fix(ffe): Isolate Feature Flags configuration listeners Keep notifying the other configuration listeners when one of them fails. Co-Authored-By: Claude Opus 5.5 --- .../internal/FeatureFlagsRuntime.java | 6 ++++- .../internal/FeatureFlagsRuntimeTest.java | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java index 57896c89f7f..b787eb0488c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -181,7 +181,11 @@ void stop() { private void onConfiguration(@Nullable final ServerConfiguration configuration) { this.configuration = configuration; for (final Consumer listener : this.listeners) { - listener.accept(configuration); + try { + listener.accept(configuration); + } catch (final RuntimeException e) { + LOGGER.warn("Feature Flags configuration listener failed", e); + } } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java index bc8545cd387..6c2284153a7 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -129,6 +129,29 @@ void removedListenersAreNotNotified() { verify(listener, org.mockito.Mockito.never()).accept(org.mockito.ArgumentMatchers.any()); } + @Test + void failingListenerDoesNotStarveOtherListeners() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + final List received = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener( + configuration -> { + throw new IllegalStateException("listener failure"); + }); + runtime.addConfigurationListener(received::add); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + } finally { + runtime.stop(); + } + + assertEquals(1, received.size()); + } + @Test void sharedRuntimeIsReferenceCounted() { final TestConfigurationSource source = new TestConfigurationSource(); From 17513705660515cd82fc5f59adb4d85412c79c96 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 09:11:05 +0200 Subject: [PATCH 19/22] feat(ffe): Connect the dd-openfeature SDK to the agent Ship the standalone dd-openfeature SDK in place of the agent-coupled provider. The new dd-openfeature-connector instrumentation detects the SDK and plugs the agent backend in through the FeatureFlaggingGateway: Remote Configuration documents, the event platform proxy, telemetry health metrics and span enrichment. The agent now forwards raw configuration documents and event payloads, UFC parsing, evaluation and event batching stay in the SDK. Remove the feature-flagging-api module and the agent-side agentless configuration and event code the SDK now owns. Keep datadog.trace.api.openfeature.Provider as a deprecated alias. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 7 +- .github/workflows/run-system-tests.yaml | 5 +- .gitlab-ci.yml | 8 +- .../dd-openfeature-connector/build.gradle | 39 + .../dd-openfeature-connector/gradle.lockfile | 130 ++ .../DDOpenFeatureInstrumentation.java | 55 + .../openfeature/AgentConfigurationSource.java | 19 + .../openfeature/AgentEventProxy.java | 22 + .../openfeature/AgentHealthMetrics.java | 19 + .../openfeature/AgentSpanEnricher.java | 25 + .../openfeature/DetectConnectorAdvice.java | 11 + .../openfeature/JavaAgentConnector.java | 63 + .../ConfigurationResolutionParityTest.java | 85 + ...tureInstrumentationDisabledForkedTest.java | 26 + .../DDOpenFeatureInstrumentationTest.java | 103 ++ dd-openfeature/README.md | 78 + dd-openfeature/build.gradle.kts | 30 +- dd-openfeature/gradle.lockfile | 85 +- .../trace/api/openfeature/Provider.java | 48 + .../trace/api/openfeature/ProviderTest.java | 32 + .../openfeature/application/build.gradle | 4 +- .../openfeature/OpenFeatureConfiguration.java | 2 +- dd-smoke-tests/openfeature/build.gradle | 3 +- dd-smoke-tests/openfeature/gradle.lockfile | 7 +- .../datadog/trace/api/ConfigDefaults.java | 2 - .../main/java/datadog/trace/api/Config.java | 56 - .../java/datadog/trace/api/ConfigTest.java | 35 - metadata/agent-jar-checks.properties | 1 + metadata/supported-configurations.json | 8 + .../feature-flagging-agent/build.gradle.kts | 2 - .../feature-flagging-agent/gradle.lockfile | 6 +- .../featureflag/FeatureFlaggingSystem.java | 196 +-- .../FeatureFlaggingSystemTest.java | 382 +---- .../feature-flagging-api/README.md | 103 -- .../feature-flagging-api/build.gradle.kts | 90 - .../feature-flagging-api/gradle.lockfile | 88 - .../FlagEvalHookHotPathBenchmark.java | 203 --- .../trace/api/openfeature/DDEvaluator.java | 1040 ------------ .../trace/api/openfeature/Evaluator.java | 17 - .../api/openfeature/FlagEvalLoggingHook.java | 168 -- .../api/openfeature/FlagEvalMetrics.java | 99 -- .../api/openfeature/FlagEvalMetricsHook.java | 41 - .../trace/api/openfeature/Provider.java | 323 ---- .../api/openfeature/SpanEnrichmentGate.java | 25 - .../api/openfeature/SpanEnrichmentHook.java | 149 -- .../DDEvaluatorSpanEnrichmentForkedTest.java | 98 -- .../api/openfeature/DDEvaluatorTest.java | 1444 ----------------- .../openfeature/FlagEvalLoggingHookTest.java | 720 -------- .../openfeature/FlagEvalMetricsHookTest.java | 126 -- .../api/openfeature/FlagEvalMetricsTest.java | 165 -- .../trace/api/openfeature/ProviderTest.java | 508 ------ .../openfeature/SpanEnrichmentHookTest.java | 241 --- .../build.gradle.kts | 25 +- .../gradle.lockfile | 2 +- .../featureflag/FeatureFlaggingGateway.java | 201 +-- .../api/featureflag/SpanEnrichmentEvent.java | 82 - .../api/featureflag/exposure/Allocation.java | 9 - .../featureflag/exposure/ExposureEvent.java | 36 - .../exposure/ExposuresRequest.java | 15 - .../trace/api/featureflag/exposure/Flag.java | 9 - .../api/featureflag/exposure/Subject.java | 13 - .../api/featureflag/exposure/Variant.java | 9 - .../flagevaluation/FlagEvalEvent.java | 98 -- .../flagevaluation/FlagEvaluationWriter.java | 46 - .../api/featureflag/ufc/v1/Allocation.java | 80 - .../ufc/v1/ConditionConfiguration.java | 18 - .../featureflag/ufc/v1/ConditionOperator.java | 19 - .../api/featureflag/ufc/v1/Environment.java | 9 - .../trace/api/featureflag/ufc/v1/Feature.java | 19 - .../trace/api/featureflag/ufc/v1/Flag.java | 25 - .../api/featureflag/ufc/v1/ParsedSemver.java | 318 ---- .../trace/api/featureflag/ufc/v1/Rule.java | 11 - .../ufc/v1/ServerConfiguration.java | 34 - .../trace/api/featureflag/ufc/v1/Shard.java | 22 - .../api/featureflag/ufc/v1/ShardRange.java | 18 - .../trace/api/featureflag/ufc/v1/Split.java | 38 - .../api/featureflag/ufc/v1/ValueType.java | 9 - .../trace/api/featureflag/ufc/v1/Variant.java | 11 - .../FeatureFlaggingGatewayTest.java | 110 +- .../featureflag/SpanEnrichmentEventTest.java | 56 - .../flagevaluation/FlagEvalEventTest.java | 54 - .../featureflag/ufc/v1/ParsedSemverTest.java | 220 --- .../config/FeatureFlaggingConfig.java | 29 +- .../feature-flagging-lib/build.gradle.kts | 25 +- .../feature-flagging-lib/gradle.lockfile | 6 +- ...gEvaluationEnqueueContentionBenchmark.java | 197 --- .../FlagEvaluationHotPathBenchmark.java | 168 -- .../AgentlessConfigurationSource.java | 489 ------ .../AgentlessFeatureFlagBackendApi.java | 101 -- .../ConfigurationSourceService.java | 10 - .../com/datadog/featureflag/EventProxy.java | 104 ++ .../datadog/featureflag/ExposureCache.java | 65 - .../datadog/featureflag/ExposureWriter.java | 14 - .../featureflag/ExposureWriterImpl.java | 210 --- .../FeatureFlagBackendApiFactory.java | 100 -- .../featureflag/FeatureFlagEventType.java | 12 +- .../featureflag/FeatureFlagEvpContext.java | 22 - .../featureflag/FeatureFlagEvpPublisher.java | 71 - .../featureflag/FeatureFlagsBackend.java | 140 ++ .../featureflag/FlagEvaluationAggregator.java | 454 ------ .../featureflag/FlagEvaluationPayloads.java | 294 ---- .../featureflag/FlagEvaluationWriterImpl.java | 608 ------- .../featureflag/JsonApiUfcResponseParser.java | 92 -- .../datadog/featureflag/LRUExposureCache.java | 36 - .../featureflag/RemoteConfigServiceImpl.java | 89 +- .../featureflag/SpanEnrichmentWriter.java | 102 +- .../UniversalFlagConfigParser.java | 641 -------- .../AgentlessConfigurationSourceTest.java | 1363 ---------------- .../AgentlessFeatureFlagBackendApiTest.java | 196 --- .../datadog/featureflag/EventProxyTest.java | 102 ++ .../featureflag/ExposureWriterTests.java | 546 ------- .../FeatureFlagBackendApiFactoryTest.java | 158 -- .../FeatureFlagEvpPublisherTest.java | 128 -- .../featureflag/FeatureFlagsBackendTest.java | 159 ++ .../FlagEvaluationAggregatorTest.java | 425 ----- .../FlagEvaluationPayloadsTest.java | 453 ------ .../FlagEvaluationTestSupport.java | 246 --- .../FlagEvaluationWriterImplTest.java | 861 ---------- .../JsonApiUfcResponseParserTest.java | 463 ------ .../featureflag/LRUExposureCacheTest.java | 311 ---- .../RemoteConfigServiceImplTest.java | 554 +------ .../featureflag/SpanEnrichmentWriterTest.java | 46 +- .../allocation-window-dates.json | 35 - .../resources/remote-config/empty-config.json | 8 - .../remote-config/malformed-allocations.json | 45 - .../resources/remote-config/null-flag.json | 34 - .../resources/remote-config/null-flags.json | 8 - .../resources/remote-config/serial-id.json | 34 - .../remote-config/sibling-serial-ids.json | 59 - .../remote-config/split-features.json | 35 - .../remote-config/unknown-operator.json | 67 - .../unknown-top-level-fields.json | 13 - settings.gradle.kts | 2 +- 133 files changed, 1731 insertions(+), 17557 deletions(-) create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java create mode 100644 dd-openfeature/README.md create mode 100644 dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java create mode 100644 dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java delete mode 100644 products/feature-flagging/feature-flagging-api/README.md delete mode 100644 products/feature-flagging/feature-flagging-api/build.gradle.kts delete mode 100644 products/feature-flagging/feature-flagging-api/gradle.lockfile delete mode 100644 products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Feature.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/EventProxyTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 660d14776d4..d0724818dd4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -355,9 +355,10 @@ **/kafka*/**/*Iast* @DataDog/asm-java @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 +/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 # @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 ba77c823f7a..b26bb177572 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/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..9285329db50 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java @@ -0,0 +1,85 @@ +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 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.InstrumenterConfig; +import datadog.trace.test.junit.utils.config.WithConfigExtension; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.extension.ExtendWith; +import org.tabletest.junit.TableTest; + +/** + * Guards against drift between the agent ({@link InstrumenterConfig}) 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. + */ +@ExtendWith(WithConfigExtension.class) +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, through {@link InstrumenterConfig}. */ + private static String agentSource(final Map settings) { + settings.forEach(WithConfigExtension::injectSysConfig); + final InstrumenterConfig config = InstrumenterConfig.get(); + return config.isFeatureFlaggingEnabled() + ? config.getFeatureFlaggingConfigurationSource() + : 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 index c047c567c13..a0a715a005b 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -4,13 +4,25 @@ import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension plugins { `java-library` idea - id("dd-trace-java.conventions.java") + id("dd-trace-java.module.distributable.api") id("dd-trace-java.version-file") id("dd-trace-java.jmh-conventions") } description = "Datadog OpenFeature provider SDK." +// 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) } @@ -43,3 +55,19 @@ jmh { 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 index 07ad4ce31c3..55a26499a91 100644 --- a/dd-openfeature/gradle.lockfile +++ b/dd-openfeature/gradle.lockfile @@ -2,87 +2,80 @@ # 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=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +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,jmhCompileClasspath,spotbugs +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,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +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,jmhCompileClasspath -dev.openfeature:sdk:1.22.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.leangen.geantyref:geantyref:1.3.16=jmhRuntimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-api:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-common:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-context:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +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=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.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-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=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +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=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +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=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +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=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +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=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.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.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: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,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +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=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.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,jmhAnnotationProcessor,spotbugsPlugins,testAnnotationProcessor +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor 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/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/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 f41834d95d2..147441f83b0 100644 --- a/dd-smoke-tests/openfeature/gradle.lockfile +++ b/dd-smoke-tests/openfeature/gradle.lockfile @@ -65,7 +65,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath org.codehaus.groovy:groovy-ant:3.0.23=codenarc org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc @@ -80,8 +80,8 @@ org.dom4j:dom4j:2.2.0=spotbugs org.gmetrics:GMetrics:2.1.0=codenarc org.hamcrest:hamcrest-core:1.3=testCompileClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core-jdk11:4.0.6=testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath @@ -116,7 +116,6 @@ org.slf4j:slf4j-api:1.7.36=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j org.slf4j:slf4j-api:2.0.18=spotbugs org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j -org.snakeyaml:snakeyaml-engine:2.9=testRuntimeClasspath org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-junit:1.2.2=testCompileClasspath,testRuntimeClasspath diff --git a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java index bf5bab30d05..aed85035b09 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java @@ -49,8 +49,6 @@ public final class ConfigDefaults { static final boolean DEFAULT_INJECT_DATADOG_ATTRIBUTE = true; static final String DEFAULT_SITE = "datadoghq.com"; static final String DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE = "agentless"; - static final int DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS = 30; - static final int DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS = 5; static final boolean DEFAULT_CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT = false; static final int DEFAULT_CODE_ORIGIN_MAX_USER_FRAMES = 8; diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 3471750c347..2085e1a0043 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -89,8 +89,6 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_BODY_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_PARAMS_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_EXPERIMENTATAL_JEE_SPLIT_BY_DEPLOYMENT; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_GRPC_CLIENT_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_GRPC_SERVER_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_HEALTH_METRICS_ENABLED; @@ -746,9 +744,6 @@ import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY; import static datadog.trace.bootstrap.instrumentation.api.WriterConstants.MULTI_WRITER_TYPE; @@ -1255,10 +1250,6 @@ public static String getHostName() { private final int remoteConfigMaxExtraServices; - private final String featureFlaggingConfigurationSourceAgentlessBaseUrl; - private final int featureFlaggingConfigurationSourcePollIntervalSeconds; - private final int featureFlaggingConfigurationSourceRequestTimeoutSeconds; - private final boolean dbmInjectSqlBaseHash; private final String dbmPropagationMode; private final boolean dbmPropagationOracleActionOnlyEnabled; @@ -2959,35 +2950,6 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) "Unsupported Feature Flagging configuration source; provider disabled: '{}'", configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE)); } - featureFlaggingConfigurationSourceAgentlessBaseUrl = - configProvider.getStringNotEmpty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, null); - int configuredFeatureFlaggingPollIntervalSeconds = - configProvider.getInteger( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS); - if (configuredFeatureFlaggingPollIntervalSeconds <= 0) { - log.warn( - "Invalid Feature Flagging agentless poll interval: {}. The value must be positive", - configuredFeatureFlaggingPollIntervalSeconds); - configuredFeatureFlaggingPollIntervalSeconds = - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; - } - featureFlaggingConfigurationSourcePollIntervalSeconds = - configuredFeatureFlaggingPollIntervalSeconds; - int configuredFeatureFlaggingRequestTimeoutSeconds = - configProvider.getInteger( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS); - if (configuredFeatureFlaggingRequestTimeoutSeconds <= 0) { - log.warn( - "Invalid Feature Flagging agentless request timeout: {}. The value must be positive", - configuredFeatureFlaggingRequestTimeoutSeconds); - configuredFeatureFlaggingRequestTimeoutSeconds = - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; - } - featureFlaggingConfigurationSourceRequestTimeoutSeconds = - configuredFeatureFlaggingRequestTimeoutSeconds; dynamicInstrumentationEnabled = configProvider.getBoolean( @@ -4929,18 +4891,6 @@ public String getFeatureFlaggingConfigurationSource() { return source == FeatureFlaggingSource.UNSET ? null : source.name().toLowerCase(Locale.ROOT); } - public String getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() { - return featureFlaggingConfigurationSourceAgentlessBaseUrl; - } - - public int getFeatureFlaggingConfigurationSourcePollIntervalSeconds() { - return featureFlaggingConfigurationSourcePollIntervalSeconds; - } - - public int getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds() { - return featureFlaggingConfigurationSourceRequestTimeoutSeconds; - } - public boolean isDynamicInstrumentationEnabled() { return dynamicInstrumentationEnabled; } @@ -6901,12 +6851,6 @@ public String toString() { + isFeatureFlaggingProviderEnabled() + ", featureFlaggingConfigurationSource=" + getFeatureFlaggingConfigurationSource() - + ", featureFlaggingConfigurationSourceAgentlessBaseUrl=" - + featureFlaggingConfigurationSourceAgentlessBaseUrl - + ", featureFlaggingConfigurationSourcePollIntervalSeconds=" - + featureFlaggingConfigurationSourcePollIntervalSeconds - + ", featureFlaggingConfigurationSourceRequestTimeoutSeconds=" - + featureFlaggingConfigurationSourceRequestTimeoutSeconds + ", debuggerEnabled=" + dynamicInstrumentationEnabled + ", debuggerUploadTimeout=" diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java index bbea22a73fa..a861dfeffac 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java @@ -1,8 +1,6 @@ package datadog.trace.api; import static datadog.trace.api.ConfigDefaults.DEFAULT_AGENT_HOST; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_HTTP_CLIENT_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_HTTP_SERVER_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_PARTIAL_FLUSH_MIN_SPANS; @@ -169,8 +167,6 @@ import static datadog.trace.api.env.FixedCapturedEnvironment.useFixedEnv; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.bootstrap.config.provider.ConfigProvider.withPropertiesOverride; import static java.util.Arrays.asList; @@ -3652,20 +3648,6 @@ void traceResourceRenamingAlwaysSimplifiedEndpointWithValue(String value, boolea assertEquals(expected, config.isTraceResourceRenamingAlwaysSimplifiedEndpoint()); } - @Test - void agentlessFeatureFlagTimingUsesPositiveConfiguredValues() throws Exception { - Properties properties = new Properties(); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, "60"); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, "4"); - - Config config = newConfig(withPropertiesOverride(properties)); - - assertEquals(60, config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()); - assertEquals(4, config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()); - } - @TableTest({ "scenario | value | expected ", "not set | | agentless ", @@ -3725,23 +3707,6 @@ void featureFlagConfigurationAppliesMigrationPrecedence( assertEquals(expectedSource, config.getFeatureFlaggingConfigurationSource()); } - @Test - void agentlessFeatureFlagTimingFallsBackForNonPositiveValues() throws Exception { - Properties properties = new Properties(); - properties.setProperty(FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, "0"); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, "-1"); - - Config config = newConfig(withPropertiesOverride(properties)); - - assertEquals( - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS, - config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()); - assertEquals( - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS, - config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()); - } - /** Reads a private {@link Config} field that has no getter. */ private static Object fieldValue(Config config, String fieldName) throws ReflectiveOperationException { diff --git a/metadata/agent-jar-checks.properties b/metadata/agent-jar-checks.properties index 82bc8070731..27b576ea1c4 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 84ee1174006..cafadc1d6b8 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 dc1fc729597..f295d3c4dc8 100644 --- a/products/feature-flagging/feature-flagging-agent/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-agent/gradle.lockfile @@ -47,7 +47,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath org.codehaus.groovy:groovy-ant:3.0.23=codenarc org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc @@ -65,8 +65,8 @@ org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt org.jacoco:org.jacoco.core:0.8.15=jacocoAnt org.jacoco:org.jacoco.report:0.8.15=jacocoAnt -org.jctools:jctools-core-jdk11:4.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath diff --git a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java index 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/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java b/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java deleted file mode 100644 index e292424e53e..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java +++ /dev/null @@ -1,203 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.ImmutableStructure; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Value; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.TearDown; -import org.openjdk.jmh.annotations.Warmup; -import org.openjdk.jmh.infra.Blackhole; - -/** - * Evaluation-thread benchmark for the OpenFeature hook: the inline cost a caller actually pays - * inside FlagEvalLoggingHook.finallyAfter, across context shapes. - * - *

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

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

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

Run: {@code ./gradlew :products:feature-flagging:feature-flagging-api:jmh - * -PjmhIncludes=FlagEvalHookHotPathBenchmark}. - */ -@State(Scope.Benchmark) -@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(NANOSECONDS) -@Fork(value = 1) -public class FlagEvalHookHotPathBenchmark { - - /** - * Context shapes. The three 100-leaf shapes (flat/100attrs, nested/10structs_10fields, - * list/10lists_10items) carry the same leaf count under different structure, so the spread - * between them isolates shape cost from leaf count. - */ - @Param({ - "flat/0attrs", - "flat/10attrs", - "flat/100attrs", - "nested/10structs_10fields", - "list/10lists_10items" - }) - public String shape; - - private HookContext hookContext; - private FlagEvaluationDetails consentOnDetails; - private FlagEvaluationDetails consentOffDetails; - private FlagEvalLoggingHook hook; - - @Setup(Level.Trial) - public void setUp() { - final MutableContext ctx = buildContext(shape); - hookContext = - HookContext.builder() - .flagKey("bench-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - - consentOnDetails = details(true); - consentOffDetails = details(false); - - // Discarding writer: isolates hook-inline cost from queue mechanics. - hook = new FlagEvalLoggingHook<>(new NoOpWriter()); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @TearDown(Level.Trial) - public void tearDown() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - } - - /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ - @Benchmark - public void hookFinallyAfter() { - hook.finallyAfter(hookContext, consentOnDetails, Collections.emptyMap()); - } - - /** Protected-path floor: consent-off skips the context copy, leaving scalar work plus enqueue. */ - @Benchmark - public void hookFinallyAfterConsentOff() { - hook.finallyAfter(hookContext, consentOffDetails, Collections.emptyMap()); - } - - /** The bounded context copy alone - the component that scales with context shape. */ - @Benchmark - public void contextCopy(final Blackhole blackhole) { - blackhole.consume(DDEvaluator.copyPrunedContext(hookContext.getCtx())); - } - - private static FlagEvaluationDetails details(final boolean observeFullEvaluationData) { - return FlagEvaluationDetails.builder() - .flagKey("bench-flag") - .value("on-value") - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "alloc-1") - .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) - .addBoolean( - DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) - .build()) - .build(); - } - - private static MutableContext buildContext(final String shape) { - final MutableContext ctx = new MutableContext("bench-user"); - if ("flat/0attrs".equals(shape)) { - return ctx; - } - if ("flat/10attrs".equals(shape)) { - return addFlat(ctx, 10); - } - if ("flat/100attrs".equals(shape)) { - return addFlat(ctx, 100); - } - if ("nested/10structs_10fields".equals(shape)) { - for (int i = 0; i < 10; i++) { - final Map inner = new HashMap<>(); - for (int j = 0; j < 10; j++) { - inner.put("field" + j, new Value("value" + j)); - } - ctx.add("struct" + i, new ImmutableStructure(inner)); - } - return ctx; - } - if ("list/10lists_10items".equals(shape)) { - for (int i = 0; i < 10; i++) { - final List items = new ArrayList<>(10); - for (int j = 0; j < 10; j++) { - items.add(new Value("value" + j)); - } - ctx.add("list" + i, items); - } - return ctx; - } - throw new IllegalArgumentException("unknown benchmark shape: " + shape); - } - - private static MutableContext addFlat(final MutableContext ctx, final int count) { - for (int i = 0; i < count; i++) { - ctx.add("field" + i, "value" + i); - } - return ctx; - } - - /** Discards events so only hook-inline work is measured. */ - private static final class NoOpWriter implements FlagEvaluationWriter { - @Override - public void enqueue(final FlagEvalEvent event) {} - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java deleted file mode 100644 index 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/Evaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java deleted file mode 100644 index f4c9cacffdb..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java +++ /dev/null @@ -1,17 +0,0 @@ -package datadog.trace.api.openfeature; - -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ProviderEvaluation; -import java.util.concurrent.TimeUnit; - -interface Evaluator { - - boolean initialize(long timeout, TimeUnit timeUnit, EvaluationContext context) throws Exception; - - boolean hasConfiguration(); - - void shutdown(); - - ProviderEvaluation evaluate( - Class target, String key, T defaultValue, EvaluationContext context); -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java deleted file mode 100644 index 322f11ac9e1..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java +++ /dev/null @@ -1,168 +0,0 @@ -package datadog.trace.api.openfeature; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import java.util.Collections; -import java.util.Map; -import java.util.function.Supplier; - -/** - * OpenFeature Hook that captures flag evaluation events for EVP flagevaluation emission. - * - *

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

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

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

The writer is resolved lazily on each call, so the hook is always safe to register - if the - * writer is absent (killswitch off or not yet started) it is a no-op. - */ -class FlagEvalLoggingHook implements Hook { - - /** - * Singleton instance: always registered when the provider is created; harmless when writer=null - * (killswitch off or not yet started). - */ - static final FlagEvalLoggingHook INSTANCE = new FlagEvalLoggingHook<>(); - - /** - * Writer resolver. Production instances resolve through FeatureFlaggingGateway; tests can inject - * a direct writer or a resolver that simulates old-bootstrap linkage failures. - */ - private final Supplier writerSupplier; - - /** Production constructor - resolves writer from gateway. */ - FlagEvalLoggingHook() { - this(FeatureFlaggingGateway::getFlagEvalWriter); - } - - /** Test-only constructor - injects a writer directly, bypassing the gateway. */ - FlagEvalLoggingHook(final FlagEvaluationWriter writer) { - this(() -> writer); - } - - /** Test-only constructor - injects a writer resolver directly, bypassing the gateway. */ - FlagEvalLoggingHook(final Supplier writerSupplier) { - this.writerSupplier = writerSupplier; - } - - /** - * Capture + non-blocking enqueue only; no flattening, aggregation, or I/O. Runs at the finally - * stage so it covers success, error, and default-value paths. - * - *

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

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

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

All work is wrapped in try/catch — enrichment must NEVER break flag evaluation. - */ -class SpanEnrichmentHook implements Hook { - - private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentHook.class); - - // The metadata keys the DDEvaluator attaches for span enrichment (single source of truth there). - static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; - static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; - - @Override - public void finallyAfter( - final HookContext ctx, - final FlagEvaluationDetails details, - final Map hints) { - if (details == null) { - return; - } - try { - final ImmutableMetadata metadata = details.getFlagMetadata(); - final Integer serialId = metadata != null ? metadata.getInteger(METADATA_SERIAL_ID) : null; - if (serialId != null) { - final boolean doLog = Boolean.TRUE.equals(metadata.getBoolean(METADATA_DO_LOG)); - FeatureFlaggingGateway.dispatch( - SpanEnrichmentEvent.serialId(serialId, doLog, targetingKey(ctx))); - } else if (details.getVariant() == null) { - // Runtime-default detection = MISSING VARIANT (never a reason enum). Unwrap any OpenFeature - // Value to a native Java type here so the seam carries only JDK types. - FeatureFlaggingGateway.dispatch( - SpanEnrichmentEvent.runtimeDefault( - details.getFlagKey(), unwrapDefaultValue(details.getValue()))); - } - } catch (final Throwable t) { - // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. - log.debug("Span-enrichment capture failed for flag {}", details.getFlagKey(), t); - } - } - - private static String targetingKey(final HookContext ctx) { - if (ctx == null) { - return null; - } - final EvaluationContext evaluationContext = ctx.getCtx(); - if (evaluationContext == null) { - return null; - } - final String key = evaluationContext.getTargetingKey(); - return (key == null || key.isEmpty()) ? null : key; - } - - /** - * Unwraps an OpenFeature {@link Value} to a native Java representation so the runtime default can - * cross the (JDK-types-only) seam and be JSON-serialized on the write side exactly like Node's - * {@code JSON.stringify}. Non-{@code Value} inputs (already native) are returned as-is. - */ - static Object unwrapDefaultValue(final Object value) { - return value instanceof Value ? unwrapValue((Value) value) : value; - } - - /** - * Recursively unwraps an OpenFeature {@link Value} into its native Java representation: - * structures become {@code Map}, lists become {@code List}, and scalars - * become their boxed value (or {@code null}). Nested {@link Value}s are unwrapped at every level - * so a structure containing further structures/lists serializes correctly. - */ - private static Object unwrapValue(final Value value) { - if (value == null || value.isNull()) { - return null; - } - if (value.isStructure()) { - final Structure structure = value.asStructure(); - final Map map = new LinkedHashMap<>(); - if (structure != null) { - for (final String key : structure.keySet()) { - map.put(key, unwrapValue(structure.getValue(key))); - } - } - return map; - } - if (value.isList()) { - final List list = value.asList(); - final List out = new ArrayList<>(list == null ? 0 : list.size()); - if (list != null) { - for (final Value element : list) { - out.add(unwrapValue(element)); - } - } - return out; - } - if (value.isBoolean()) { - return value.asBoolean(); - } - if (value.isString()) { - return value.asString(); - } - if (value.isNumber()) { - // Preserve integral vs fractional so the rendered JSON number matches Node. - final Double d = value.asDouble(); - if (d != null && d == Math.rint(d) && !Double.isInfinite(d)) { - final Integer i = value.asInteger(); - if (i != null) { - return i; - } - } - return d; - } - final Instant instant = value.asInstant(); - if (instant != null) { - return instant.toString(); - } - // Unknown shape: fall back to the wrapped object's own representation. - return value.asObject(); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/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-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java deleted file mode 100644 index 940c84998d4..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ /dev/null @@ -1,1444 +0,0 @@ -package datadog.trace.api.openfeature; - -import static dev.openfeature.sdk.Reason.ERROR; -import static java.util.Arrays.asList; -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.greaterThan; -import static org.hamcrest.Matchers.hasEntry; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; -import datadog.trace.api.featureflag.ufc.v1.Rule; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Value; -import java.io.IOException; -import java.lang.reflect.Type; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -public class DDEvaluatorTest { - - private static final String CANONICAL_FIXTURE_PATH = - "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Date.class, new DateAdapter()) - .add(ShardAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .build(); - private static final JsonAdapter CONFIG_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - private static final Type FIXTURE_LIST_TYPE = - Types.newParameterizedType(List.class, FixtureCase.class); - private static final JsonAdapter> FIXTURE_LIST_ADAPTER = - MOSHI.adapter(FIXTURE_LIST_TYPE); - private static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; - - @Test - public void testInitializeSignalsApplicationProviderActivation() throws Exception { - final FeatureFlaggingGateway.ActivationListener listener = - mock(FeatureFlaggingGateway.ActivationListener.class); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - FeatureFlaggingGateway.addActivationListener(listener); - try { - evaluator.initialize(1, MILLISECONDS, mock(EvaluationContext.class)); - - verify(listener).activate(); - } finally { - evaluator.shutdown(); - FeatureFlaggingGateway.removeActivationListener(listener); - } - } - - private static Arguments[] valueMappingTestCases() { - return new Arguments[] { - // String mappings - Arguments.of(String.class, "hello", "hello"), - Arguments.of(String.class, 123, "123"), - Arguments.of(String.class, true, "true"), - Arguments.of(String.class, 3.14, "3.14"), - Arguments.of(String.class, null, null), - - // Boolean mappings - Arguments.of(Boolean.class, true, true), - Arguments.of(Boolean.class, false, false), - Arguments.of(Boolean.class, "true", true), - Arguments.of(Boolean.class, "false", false), - Arguments.of(Boolean.class, "TRUE", true), - Arguments.of(Boolean.class, "FALSE", false), - Arguments.of(Boolean.class, 1, true), - Arguments.of(Boolean.class, 0, false), - Arguments.of(Boolean.class, null, null), - - // Integer mappings - Arguments.of(Integer.class, 42, 42), - Arguments.of(Integer.class, "42", 42), - Arguments.of(Integer.class, 3.14, 3), - Arguments.of(Integer.class, "3.14", 3), - Arguments.of(Integer.class, null, null), - - // Double mappings - Arguments.of(Double.class, 3.14, 3.14), - Arguments.of(Double.class, "3.14", 3.14), - Arguments.of(Double.class, 42, 42.0), - Arguments.of(Double.class, "42", 42.0), - Arguments.of(Double.class, null, null), - - // Value mappings (OpenFeature Value objects) - Arguments.of(Value.class, "hello", Value.objectToValue("hello")), - Arguments.of(Value.class, 42, Value.objectToValue(42)), - Arguments.of(Value.class, 3.14, Value.objectToValue(3.14)), - Arguments.of(Value.class, true, Value.objectToValue(true)), - Arguments.of(Value.class, null, null), - - // Unsupported - Arguments.of(Long.class, 42L, IllegalArgumentException.class), - }; - } - - @ParameterizedTest - @MethodSource("valueMappingTestCases") - public void testValueMapping(final Class target, final Object value, final Object expected) { - if (expected == IllegalArgumentException.class) { - assertThrows(IllegalArgumentException.class, () -> DDEvaluator.mapValue(target, value)); - } else { - final Object result = DDEvaluator.mapValue(target, value); - assertThat(result, equalTo(expected)); - } - } - - @Test - public void testEvaluateNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - } - - @Test - public void testInitializeTimesOutWithoutConfig() throws Exception { - final Runnable configCallback = mock(Runnable.class); - final DDEvaluator evaluator = new DDEvaluator(configCallback); - evaluator.accept(null); - try { - assertThat( - evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); - verify(configCallback, times(0)).run(); - } finally { - evaluator.shutdown(); - } - } - - @Test - public void testInitializeWaitsForNonNullConfig() throws Exception { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - final Future initialized = - executor.submit(() -> evaluator.initialize(1, SECONDS, mock(EvaluationContext.class))); - - evaluator.accept(null); - assertThat(initialized.isDone(), equalTo(false)); - - evaluator.accept(mock(ServerConfiguration.class)); - assertThat(initialized.get(1, SECONDS), equalTo(true)); - } finally { - executor.shutdownNow(); - evaluator.shutdown(); - } - } - - @Test - public void testEvaluateNoContext() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(mock(ServerConfiguration.class)); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.INVALID_CONTEXT)); - } - - @Test - public void testNoAllocations() { - final Map flags = new HashMap<>(); - flags.put("null-allocation", new Flag("target", true, null, null, null)); - flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); - - ProviderEvaluation details = evaluator.evaluate(Integer.class, "null-allocation", 23, ctx); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.GENERAL)); - - details = evaluator.evaluate(Integer.class, "empty-allocation", 23, ctx); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat(details.getErrorCode(), nullValue()); - } - - @Test - public void testEvaluateUnsignedShardRange() { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - // The selected shard is above Integer.MAX_VALUE, so this test proves that evaluation uses - // unsigned semantics after binary-compatible int storage. - final Shard shard = - new Shard( - "salt", - singletonList(new ShardRange((int) 3_699_531_192L, (int) 3_699_531_193L)), - (int) MAX_UNSIGNED_INT); - final Split split = new Split(singletonList(shard), "on", emptyMap(), null); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - final Map flags = new HashMap<>(); - flags.put( - "target", - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); - - final EvaluationContext context = - new MutableContext("target").setTargetingKey("high-shard-user"); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, context); - - assertThat(details.getValue(), equalTo(1)); - assertThat(details.getReason(), equalTo("SPLIT")); - assertThat(details.getVariant(), equalTo("on")); - } - - // ---- observeFullEvaluationData metadata is stamped from the evaluator's ServerConfiguration - // ---- - // - // Every code path that returns a ProviderEvaluation must stamp the consent boolean so downstream - // hooks can honour it. These tests exercise each stamp site with both consent values (on/off) so - // a mutation to any stamp — deleting the line, hardcoding the value — flips at least one - // assertion. - - // -- success path: resolveVariant (variant metadata builder) -- - - @Test - public void observeFullEvaluationDataStampedTrueOnResolvedVariant() { - final ProviderEvaluation details = evaluateMatchingFlag(true); - - assertThat(details.getReason(), equalTo("STATIC")); - assertThat(details.getVariant(), equalTo("on")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { - // Symmetric consent-off assertion. Paired with the consent-on test above this pins the - // resolveVariant metadata line (DDEvaluator.java: METADATA_OBSERVE_FULL_EVALUATION_DATA) so - // deleting it or hardcoding either value would fail at least one assertion. - final ProviderEvaluation details = evaluateMatchingFlag(false); - - assertThat(details.getReason(), equalTo("STATIC")); - assertThat(details.getVariant(), equalTo("on")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- DISABLED path: flag.enabled=false -- - - @Test - public void observeFullEvaluationDataStampedTrueOnDisabledFlag() { - final ProviderEvaluation details = evaluateDisabledFlag(true); - - assertThat(details.getReason(), equalTo("DISABLED")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnDisabledFlag() { - final ProviderEvaluation details = evaluateDisabledFlag(false); - - assertThat(details.getReason(), equalTo("DISABLED")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- DEFAULT path: no allocation matches -- - - @Test - public void observeFullEvaluationDataStampedTrueOnDefault() { - // Allocation exists but has empty splits, so the loop finishes without returning and we fall - // through to the DEFAULT branch. - final ProviderEvaluation details = evaluateWithEmptySplits(true); - - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnDefault() { - final ProviderEvaluation details = evaluateWithEmptySplits(false); - - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- error paths: FLAG_NOT_FOUND / PROVIDER_NOT_READY (via consentMetadata in error()) -- - - @Test - public void observeFullEvaluationDataStampedOnFlagNotFoundError() { - // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via - // FLAG_NOT_FOUND — kept for that stamp site, correctly named. - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "unknown-flag", 23, ctx); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.FLAG_NOT_FOUND)); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - @Test - public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { - // The field is boxed so Moshi tolerates a malformed consent value in the UFC JSON without - // aborting the whole parse. The evaluator must then interpret null as the privacy-preserving - // default. An auto-unbox at the read site (config.observeFullEvaluationData) would NPE here. - final Map flags = new HashMap<>(); - flags.put("target", new Flag("target", true, ValueType.INTEGER, emptyMap(), emptyList())); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, ctx); - - // Flags still evaluate — availability preserved despite the malformed consent field. - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // ---- exposure events carry the split's serial id ---- - - @Test - public void exposureCarriesTheSplitSerialId() { - assertEquals(Integer.valueOf(340132), exposureFor(340132).serial_id); - } - - @Test - public void exposureCarriesSerialIdZero() { - assertEquals(Integer.valueOf(0), exposureFor(0).serial_id); - } - - @Test - public void exposureOmitsSerialIdWhenTheSplitHasNone() { - assertNull(exposureFor(null).serial_id); - } - - @Test - public void legacyExposureApiDispatchesAnExposureWithoutSerialId() { - final boolean previous = DDEvaluator.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 - * the serial id does not travel via the enrichment-gated evaluation metadata. - */ - 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); - } - assertEquals(1, dispatched.size()); - return dispatched.get(0); - } - - // Builds a flag that reaches resolveVariant: enabled, one allocation with no rules, one split - // with empty shards (so the shard-match branch is skipped and the split is picked immediately), - // and a single "on" variant whose value maps to the requested Integer type. - private static ProviderEvaluation evaluateMatchingFlag( - final boolean observeFullEvaluationData) { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), null); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - return evaluateFlag( - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), - observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateDisabledFlag( - final boolean observeFullEvaluationData) { - return evaluateFlag( - new Flag("target", false, ValueType.INTEGER, emptyMap(), null), observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateWithEmptySplits( - final boolean observeFullEvaluationData) { - // Enabled, allocations present, allocation active, no rules, empty splits → falls through the - // for-loop to the DEFAULT return. - final Allocation allocation = - new Allocation("alloc-1", null, null, null, emptyList(), Boolean.FALSE); - return evaluateFlag( - new Flag("target", true, ValueType.INTEGER, emptyMap(), singletonList(allocation)), - observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateFlag( - final Flag flag, final boolean observeFullEvaluationData) { - final Map flags = new HashMap<>(); - flags.put("target", flag); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); - return evaluator.evaluate(Integer.class, "target", 23, ctx); - } - - // ---- error message redaction respects observeFullEvaluationData ---- - - @Test - public void numericConditionOnTargetingKeyDropsExceptionMessageUnderConsentOff() { - // Rule {attribute:"id", operator:GT, value:0} + "id" not in context → - // DDEvaluator.resolveAttribute - // falls back to the targeting key, so Double.parseDouble("jane.doe@datadoghq.com") throws - // NumberFormatException. The exception message echoes the raw context value verbatim, so it - // must be dropped when observeFullEvaluationData=false. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); - assertNull(details.getErrorMessage(), "consent-off must not surface the raw exception message"); - } - - @Test - public void numericConditionOnTargetingKeyPreservesExceptionMessageUnderConsentOn() { - // Symmetric case: with consent on, the raw exception message flows through unchanged so - // operators keep the diagnostic detail they opted in to. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", true); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); - assertThat(details.getErrorMessage(), equalTo("For input string: \"jane.doe@datadoghq.com\"")); - } - - @Test - public void numericConditionOnTargetingKeyErrorMessageNeverContainsPiiUnderConsentOff() { - // Belt-and-suspenders: independent of the exact null/empty form, the raw PII value must never - // appear in the message under consent-off. Guards against future changes that might replace - // null with a redacted string or a code-name suffix. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); - - final String message = details.getErrorMessage(); - assertFalse( - message != null && message.contains("jane.doe@datadoghq.com"), - "consent-off errorMessage must not contain raw context values"); - } - - private static ProviderEvaluation evaluateWithNumericRuleOnId( - final String targetingKey, final boolean observeFullEvaluationData) { - final Map flags = new HashMap<>(); - final List rules = - singletonList( - new Rule(singletonList(new ConditionConfiguration(ConditionOperator.GT, "id", 0)))); - // Split must be non-empty so the allocation is considered a match target; its contents don't - // matter because the rule throws before a split is picked. - final Allocation allocation = - new Allocation("alloc", rules, null, null, emptyList(), Boolean.FALSE); - flags.put( - "num-rule", - new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); - - final EvaluationContext ctx = new MutableContext(targetingKey); - return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); - } - - @Test - public void testAllocationDateAbiAndInstantAccessors() throws Exception { - final Date startAt = Date.from(Instant.parse("2024-01-01T00:00:00Z")); - final Date endAt = Date.from(Instant.parse("2024-12-31T23:59:59Z")); - final Allocation allocation = - new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); - - assertThat(Allocation.class.getField("startAt").getType(), equalTo(Date.class)); - assertThat(Allocation.class.getField("endAt").getType(), equalTo(Date.class)); - assertThat(allocation.startAtInstant(), equalTo(startAt.toInstant())); - assertThat(allocation.endAtInstant(), equalTo(endAt.toInstant())); - } - - @Test - public void testAllocationWindowHonorsMicrosecondPrecision() { - final Instant startAt = Instant.parse("2024-01-01T00:00:00.123456Z"); - final Instant endAt = Instant.parse("2024-01-01T00:00:00.987654Z"); - final Allocation allocation = - Allocation.fromInstants("allocation", emptyList(), startAt, endAt, emptyList(), true); - - assertThat( - DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); - assertThat(DDEvaluator.isAllocationActive(allocation, startAt), equalTo(true)); - assertThat(DDEvaluator.isAllocationActive(allocation, endAt), equalTo(true)); - assertThat(DDEvaluator.isAllocationActive(allocation, endAt.plusNanos(1_000)), equalTo(false)); - } - - // --- SemVer condition evaluation tests (ported from Go evaluator_test.go) --- - - private static Flag semverFlag(final ConditionOperator operator, final String comparand) { - final ParsedSemver parsed = ParsedSemver.parse(comparand); - final ConditionConfiguration condition = - new ConditionConfiguration(operator, "version", comparand); - condition.semverComparand = parsed; - final Rule rule = new Rule(singletonList(condition)); - final Split split = new Split(emptyList(), "on", null, null); - final Allocation allocation = - Allocation.fromInstants( - "targeted", singletonList(rule), null, null, singletonList(split), false); - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", true)); - return new Flag("test-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation)); - } - - private static EvaluationContext semverContext(final Object version) { - final Map attributes = new HashMap<>(); - if (version != null) { - attributes.put("version", version); - } - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - context.setTargetingKey("subject"); - return context; - } - - static Arguments[] semverConditionTestCases() { - return new Arguments[] { - // Equal - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3", "1.2.3", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.4", "1.2.3", false), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3.4.5.6", "1.2.3.4.5.6", true), - Arguments.of(ConditionOperator.SEMVER_GT, "1.2.3.4.5.7", "1.2.3.4.5.6", true), - // Not equal - Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.4", "1.2.3", true), - Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.3", "1.2.3", false), - // Less than - Arguments.of(ConditionOperator.SEMVER_LT, "1.9.9", "2.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LT, "2.0.0", "2.0.0", false), - // Less than or equal - Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.0", "2.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.1", "2.0.0", false), - // Greater than - Arguments.of(ConditionOperator.SEMVER_GT, "1.0.1", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GT, "1.0.0", "1.0.0", false), - // Greater than or equal - Arguments.of(ConditionOperator.SEMVER_GTE, "1.0.0", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GTE, "0.9.9", "1.0.0", false), - // Prerelease ordering - Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.1", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.2", "1.0.0-beta.11", true), - // Build metadata is ignored - Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+exp.sha.5114f85", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_NEQ, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_LT, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_LTE, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GT, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.0.0+linux", "1.0.0+darwin", true), - // Invalid attribute does not match - Arguments.of(ConditionOperator.SEMVER_NEQ, "not-a-version", "1.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "1.2", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GTE, "v1.2.3", "1.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "18446744073709551616.0.0", "1.0.0", false), - // Non-string attribute does not match - Arguments.of(ConditionOperator.SEMVER_EQ, 1.2, "1.2.0", false), - }; - } - - @ParameterizedTest(name = "{0} attr={1} comparand={2} -> {3}") - @MethodSource("semverConditionTestCases") - public void testEvaluateSemverCondition( - final ConditionOperator operator, - final Object attribute, - final String comparand, - final boolean wantMatch) { - final Map flags = new HashMap<>(); - flags.put("test-flag", semverFlag(operator, comparand)); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); - - if (wantMatch) { - assertThat(details.getValue(), equalTo(true)); - assertThat(details.getReason(), equalTo("TARGETING_MATCH")); - } else { - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo("DEFAULT")); - } - } - - @Test - public void testEvaluateSemverConditionMissingAttribute() { - final Map flags = new HashMap<>(); - flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo("DEFAULT")); - } - - @Test - public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { - // A flag with an invalid semver comparand is dropped during parsing. - // The evaluator should return PARSE_ERROR when the flag is queried. - final Map flags = new HashMap<>(); - final Map invalidFlags = new HashMap<>(); - invalidFlags.put("invalid-semver", "invalid_semver_comparand"); - final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); - config.invalidFlags = invalidFlags; - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); - } - - @Test - public void testEvaluateMalformedFlagReturnsParseError() { - final Map flags = new HashMap<>(); - final Map invalidFlags = new HashMap<>(); - invalidFlags.put("malformed-flag", "invalid_flag"); - final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); - config.invalidFlags = invalidFlags; - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "malformed-flag", false, new MutableContext()); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); - } - - private static Arguments[] flatteningTestCases() { - final List arguments = new ArrayList<>(); - arguments.add(Arguments.of(emptyMap(), emptyMap())); - arguments.add( - Arguments.of( - mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null), - mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null))); - arguments.add( - Arguments.of( - mapOf("list", asList(1, 2, singletonList(4))), - mapOf("list[0]", 1, "list[1]", 2, "list[2][0]", 4))); - arguments.add( - Arguments.of( - mapOf("map", mapOf("key1", 1, "key2", 2, "key3", mapOf("key4", 4))), - mapOf("map.key1", 1, "map.key2", 2, "map.key3.key4", 4))); - arguments.add( - Arguments.of( - mapOf("plan", "gold", "cohort", "gold"), mapOf("plan", "gold", "cohort", "gold"))); - final Instant instant = Instant.parse("2026-07-10T12:34:56Z"); - arguments.add(Arguments.of(mapOf("instant", instant), mapOf("instant", instant.toString()))); - return arguments.toArray(new Arguments[0]); - } - - @MethodSource("flatteningTestCases") - @ParameterizedTest - public void testFlattening( - final Map attributes, final Map expected) { - final EvaluationContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - final Map result = DDEvaluator.flattenContext(context); - - assertThat(result.size(), equalTo(expected.size())); - for (final Map.Entry entry : expected.entrySet()) { - assertThat(result, hasEntry(entry.getKey(), entry.getValue())); - } - } - - @Test - public void testDeeplyNestedContextIsTruncatedRatherThanOverflowingTheStack() { - Value nested = new Value("leaf"); - for (int i = 0; i < 10_000; i++) { - nested = new Value(singletonList(nested)); - } - final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); - - final Map result = DDEvaluator.flattenContext(context); - - final StringBuilder truncatedKey = new StringBuilder("deep"); - for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { - truncatedKey.append("[0]"); - } - assertThat(result.size(), equalTo(1)); - assertThat(result, hasEntry(truncatedKey.toString(), null)); - } - - @Test - public void testCopyPrunedContextCapsTopLevelFieldCount() { - final MutableContext context = new MutableContext(); - for (int i = 0; i < DDEvaluator.MAX_CONTEXT_FIELDS + 100; i++) { - context.add(String.format("k%04d", i), "v"); - } - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs.size(), equalTo(DDEvaluator.MAX_CONTEXT_FIELDS)); - assertThat(result.truncatedReason, equalTo("max_context_fields")); - } - - @Test - public void testCopyPrunedContextSkipsOversizedStringValues() { - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add("drop", new String(longChars)); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("keep", "ok")); - assertThat(result.attrs.containsKey("drop"), equalTo(false)); - assertThat(result.truncatedReason, equalTo("max_value_length")); - } - - @Test - public void testCopyPrunedContextSkipsOversizedKeys() { - final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; - java.util.Arrays.fill(longKeyChars, 'k'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add(new String(longKeyChars), "drop"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("keep", "ok")); - assertThat(result.attrs.size(), equalTo(1)); - assertThat(result.truncatedReason, equalTo("max_key_length")); - } - - @Test - public void testCopyPrunedContextCapsListWidth() { - final List wide = new java.util.ArrayList<>(); - for (int i = 0; i < DDEvaluator.MAX_LIST_ELEMENTS + 50; i++) { - wide.add(Value.objectToValue("v" + i)); - } - final EvaluationContext context = new MutableContext().add("list", wide); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs.containsKey("list[0]"), equalTo(true)); - assertThat( - result.attrs.containsKey("list[" + (DDEvaluator.MAX_LIST_ELEMENTS - 1) + "]"), - equalTo(true)); - assertThat( - result.attrs.containsKey("list[" + DDEvaluator.MAX_LIST_ELEMENTS + "]"), equalTo(false)); - assertThat(result.truncatedReason, equalTo("max_list_elements")); - } - - @Test - public void testCopyPrunedContextCapsStructureWidth() { - final dev.openfeature.sdk.MutableStructure wide = new dev.openfeature.sdk.MutableStructure(); - for (int i = 0; i < DDEvaluator.MAX_STRUCTURE_PROPERTIES + 50; i++) { - wide.add(String.format("p%04d", i), "v"); - } - final EvaluationContext context = new MutableContext().add("struct", wide); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - long structKeys = result.attrs.keySet().stream().filter(k -> k.startsWith("struct.")).count(); - assertThat(structKeys, equalTo((long) DDEvaluator.MAX_STRUCTURE_PROPERTIES)); - assertThat(result.truncatedReason, equalTo("max_structure_properties")); - } - - @Test - public void testCopyPrunedContextTruncatesDeepNesting() { - Value nested = new Value("leaf"); - for (int i = 0; i < 10_000; i++) { - nested = new Value(singletonList(nested)); - } - final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - final StringBuilder truncatedKey = new StringBuilder("deep"); - for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { - truncatedKey.append("[0]"); - } - // The recursion stops on the first list element at MAX_SNAPSHOT_DEPTH; deeper elements are - // never walked and no entry is emitted for them. - assertThat(result.attrs.containsKey(truncatedKey.toString()), equalTo(false)); - assertThat(result.attrs.size(), equalTo(0)); - assertThat(result.truncatedReason, equalTo("max_snapshot_depth")); - } - - @Test - public void testCopyPrunedContextExcludesTargetingKey() { - final MutableContext context = new MutableContext("user-42").add("region", "us-east-1"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("region", "us-east-1")); - assertThat(result.attrs.containsKey("targetingKey"), equalTo(false)); - assertThat(result.truncatedReason, equalTo(null)); - } - - @Test - public void testCopyPrunedContextNoTruncationReturnsNullReason() { - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - context.add("tier", "gold"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("region", "us-east-1")); - assertThat(result.truncatedReason, equalTo(null)); - } - - @Test - public void testCopyPrunedContextMultipleReasonsAreSortedAndDeduplicated() { - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; - java.util.Arrays.fill(longKeyChars, 'k'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add("dropValue", new String(longChars)); - context.add(new String(longKeyChars), "dropKey"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - // Both max_key_length and max_value_length fired; sorted alphabetically, no duplicates. - assertThat(result.truncatedReason, equalTo("max_key_length,max_value_length")); - } - - @Test - public void testCanonicalFixturesArePresent() throws IOException { - assertThat(canonicalTestCases().size(), greaterThan(0)); - } - - @MethodSource("canonicalTestCases") - @ParameterizedTest(name = "{0}") - public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(loadCanonicalConfiguration()); - - final Class targetType = targetType(testCase.variationType); - final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); - final Object expectedValue = mapFixtureValue(targetType, testCase.result.value); - final ProviderEvaluation details = - evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); - - assertThat(details.getValue(), equalTo(expectedValue)); - assertThat(details.getReason(), equalTo(testCase.result.reason)); - if (testCase.result.variant != null) { - assertThat(details.getVariant(), equalTo(testCase.result.variant)); - } - if (testCase.result.errorCode != null) { - assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.result.errorCode))); - } - if (testCase.result.flagMetadata != null - && testCase.result.flagMetadata.get("allocationKey") != null) { - assertThat( - details.getFlagMetadata().getString("allocationKey"), - equalTo(String.valueOf(testCase.result.flagMetadata.get("allocationKey")))); - } - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private static ProviderEvaluation evaluate( - final DDEvaluator evaluator, - final Class targetType, - final String flag, - final Object defaultValue, - final EvaluationContext context) { - return evaluator.evaluate((Class) targetType, flag, defaultValue, context); - } - - private static ServerConfiguration loadCanonicalConfiguration() throws IOException { - INVALID_FLAGS_HOLDER.get().clear(); - try { - final ServerConfiguration config = - CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); - final Map invalidFlags = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; - } - validateAndCacheSemverComparands(config); - return config; - } finally { - INVALID_FLAGS_HOLDER.get().clear(); - } - } - - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in the configuration. Flags - * with invalid comparands are removed from the flags map and tracked in invalidFlags, matching - * the behavior of {@link com.datadog.featureflag.UniversalFlagConfigParser}. - */ - private static void validateAndCacheSemverComparands(final ServerConfiguration config) { - if (config.flags == null) { - return; - } - final Map invalidFlags = - config.invalidFlags == null ? new HashMap<>() : new HashMap<>(config.invalidFlags); - final Map flagsToRemove = new HashMap<>(); - for (final Map.Entry entry : config.flags.entrySet()) { - final String flagKey = entry.getKey(); - final Flag flag = entry.getValue(); - if (flag.allocations == null) { - continue; - } - boolean invalid = false; - for (final Allocation allocation : flag.allocations) { - if (allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; - } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - invalid = true; - break; - } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - invalid = true; - break; - } - condition.semverComparand = parsed; - break; - default: - break; - } - } - } - } - if (invalid) { - flagsToRemove.put(flagKey, flag); - invalidFlags.put(flagKey, "invalid_semver_comparand"); - } - } - for (final String flagKey : flagsToRemove.keySet()) { - config.flags.remove(flagKey); - } - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; - } - } - - private static List canonicalTestCases() throws IOException { - final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); - final List result = new ArrayList<>(); - - try (final Stream paths = Files.list(evaluationCases)) { - final List files = - paths - .filter(path -> path.getFileName().toString().endsWith(".json")) - .sorted((left, right) -> left.getFileName().compareTo(right.getFileName())) - .collect(Collectors.toList()); - for (final Path file : files) { - final List testCases = FIXTURE_LIST_ADAPTER.fromJson(read(file)); - if (testCases == null) { - throw new JsonDataException("Fixture file did not contain an array: " + file); - } - for (int index = 0; index < testCases.size(); index++) { - final FixtureCase testCase = testCases.get(index); - testCase.fileName = file.getFileName().toString(); - testCase.index = index; - result.add(testCase); - } - } - } - - assertThat(result.size(), greaterThan(0)); - return result; - } - - private static Path fixtureRoot() { - Path directory = Paths.get("").toAbsolutePath(); - while (directory != null) { - final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); - if (Files.exists(candidate.resolve("ufc-config.json")) - && Files.isDirectory(candidate.resolve("evaluation-cases"))) { - return candidate; - } - directory = directory.getParent(); - } - throw new IllegalStateException("Unable to find canonical FFE fixtures"); - } - - private static String read(final Path path) throws IOException { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static EvaluationContext context(final FixtureCase testCase) { - final Map attributes = - testCase.attributes == null ? emptyMap() : testCase.attributes; - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - if (testCase.targetingKey != null) { - context.setTargetingKey(testCase.targetingKey); - } - return context; - } - - private static Class targetType(final String variationType) { - switch (variationType) { - case "BOOLEAN": - return Boolean.class; - case "INTEGER": - return Integer.class; - case "NUMERIC": - return Double.class; - case "STRING": - return String.class; - case "JSON": - return Value.class; - default: - throw new IllegalArgumentException("Unsupported variationType: " + variationType); - } - } - - private static Object mapFixtureValue(final Class targetType, final Object value) { - return DDEvaluator.mapValue(targetType, value); - } - - private static Map mapOf(final Object... props) { - final Map result = new HashMap<>(props.length << 1); - int index = 0; - while (index < props.length) { - final String key = String.valueOf(props[index++]); - final Object value = props[index++]; - result.put(key, value); - } - return result; - } - - private static final class FixtureCase { - Map attributes = emptyMap(); - Object defaultValue; - String flag; - FixtureResult result; - String targetingKey; - String variationType; - transient String fileName; - transient int index; - - @Override - public String toString() { - return fileName + "[" + index + "] flag=" + flag; - } - } - - private static final class FixtureResult { - Object value; - String reason; - String errorCode; - String variant; - Map flagMetadata = emptyMap(); - } - - /** - * Mirrors the production parser's binary-compatible uint32 representation for fixture parsing. - */ - private static final class ShardAdapter extends JsonAdapter { - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || type != Shard.class) { - return null; - } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - }; - - private final JsonAdapter delegate; - - private ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Override - public Shard fromJson(final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); - } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); - } - - @Override - public void toJson(final JsonWriter writer, final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static int toUnsignedInt(final Long value, final String fieldName) { - if (value == null) { - return 0; - } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new IllegalArgumentException("flag contains an invalid " + fieldName + " value"); - } - return value.intValue(); - } - - private static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } - - private static final class ShardRangeJson { - Long start; - Long end; - } - - /** Reads the flags map with per-flag failure isolation, matching the production parser. */ - private static final class FlagMapAdapter extends JsonAdapter> { - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - }; - - private final JsonAdapter flagAdapter; - - private FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } - - @Override - public Map fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException ignored) { - INVALID_FLAGS_HOLDER.get().put(flagKey, "invalid_flag"); - // A malformed flag must not prevent valid flags in the same configuration from loading. - } - } - reader.endObject(); - return flags; - } - - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; - } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; - } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains a split with missing shards"); - } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains invalid shards"); - } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } - } - } - } - } - } - - private static void validateConditionOperands( - final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; - } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-numeric condition"); - } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-list condition"); - } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-boolean condition"); - } - break; - default: - break; - } - } - } - } - - @Override - public void toJson(final JsonWriter writer, final Map value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static final class DateAdapter extends JsonAdapter { - @Override - public Date fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - try { - return Date.from( - DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from)); - } catch (final Exception ignored) { - return null; - } - } - - @Override - public void toJson(final JsonWriter writer, final Date value) throws IOException { - if (value == null) { - writer.nullValue(); - return; - } - writer.value(value.toInstant().toString()); - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java deleted file mode 100644 index 3b7910d4712..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java +++ /dev/null @@ -1,720 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.MutableStructure; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Value; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link FlagEvalLoggingHook}: context capture, non-blocking enqueue, eval-time - * metadata, absent-variant detection, and killswitch-via-writer-null behaviour. - */ -class FlagEvalLoggingHookTest { - - @BeforeEach - void enableFlagEvaluationEnqueue() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @AfterEach - void resetFlagEvaluationEnqueue() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - // Clear any dispatched UFC so an observeFullEvaluationData value can't leak into other tests - // that share the static gateway. - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - // ---- helpers ---- - - /** - * Creates a writer that captures the enqueued event for assertion. Uses an anonymous class since - * FlagEvaluationWriter has multiple abstract methods. - */ - private FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - }; - } - - private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationWriter writer) { - return new FlagEvalLoggingHook<>(writer); - } - - private static FlagEvaluationDetails details( - final String flagKey, - final Object value, - final String variant, - final String reason, - final ImmutableMetadata metadata) { - final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = - FlagEvaluationDetails.builder().flagKey(flagKey).value(value).reason(reason); - if (variant != null) { - builder.variant(variant); - } - if (metadata != null) { - builder.flagMetadata(metadata); - } - return builder.build(); - } - - private static HookContext hookCtxWithTargetingKey( - final String flagKey, final String targetingKey) { - final MutableContext ctx = new MutableContext(targetingKey); - return HookContext.builder() - .flagKey(flagKey) - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - } - - // ---- test: hook calls writer.enqueue once with flagKey, variant, allocationKey ---- - - @Test - void finallyAfterEnqueuesEventWithAllBasicFields() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details( - "my-flag", - "on-value", - "on", - Reason.TARGETING_MATCH.name(), - ImmutableMetadata.builder().addString("allocationKey", "alloc-1").build()); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get(), "writer.enqueue must be called once"); - final FlagEvalEvent e = captured.get(); - assertEquals("my-flag", e.flagKey); - assertEquals("on", e.variant, "variant must be the OpenFeature variant key"); - assertEquals("alloc-1", e.allocationKey); - } - - // ---- variant comes from details.getVariant(), NOT details.getValue() ---- - - @Test - void variantIsTheVariantKeyNotTheEvaluatedValue() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - // value and variant DIFFER, so a value-vs-variant mistake is detectable. - final FlagEvaluationDetails det = - details( - "g1-flag", - "the-evaluated-value", // value - "the-variant-key", // variant - Reason.TARGETING_MATCH.name(), - null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "the-variant-key", - captured.get().variant, - "variant must be sourced from details.getVariant(), not details.getValue()"); - } - - // ---- test: evalTimeMs from metadata "__dd_eval_timestamp_ms" ---- - - @Test - void evalTimeMsComesFromMetadataWhenPresent() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final long expectedTimestamp = 1_700_000_000_000L; - final FlagEvaluationDetails det = - details( - "ts-flag", - "v", - "v", - Reason.SPLIT.name(), - ImmutableMetadata.builder() - .addString("allocationKey", "a") - .addLong("__dd_eval_timestamp_ms", expectedTimestamp) - .build()); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - expectedTimestamp, - captured.get().evalTimeMs, - "evalTimeMs must come from __dd_eval_timestamp_ms metadata when present"); - } - - // ---- test: evalTimeMs falls back to System.currentTimeMillis() when absent ---- - - @Test - void evalTimeMsFallsBackToCurrentTimeWhenMetadataAbsent() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final long before = System.currentTimeMillis(); - final FlagEvaluationDetails det = - details("ts-flag", "v", "v", Reason.SPLIT.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - final long after = System.currentTimeMillis(); - assertNotNull(captured.get()); - final long ts = captured.get().evalTimeMs; - assertTrue( - ts >= before && ts <= after, - "evalTimeMs must fall back to hook-fire time when metadata absent. got: " + ts); - } - - // ---- test: absent variant -> variant is null -> runtime default ---- - - @Test - void absentVariantProducesNullVariant() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - // A runtime default returns the default value but no variant. - final FlagEvaluationDetails det = - details("def-flag", "default-value", null, Reason.DEFAULT.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().variant, "Absent variant must stay null (runtime default)"); - } - - // ---- test: error message captured from details (error object support) ---- - - @Test - void errorMessageCapturedFromDetailsUnderConsentOn() { - // With observeFullEvaluationData=true the provider's raw message is preserved verbatim so - // operators keep the diagnostic detail they opted in to. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage("value does not match declared type") - .flagMetadata(consentOnMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "value does not match declared type", - captured.get().errorMessage, - "errorMessage must be captured from the evaluation details under consent-on"); - } - - @Test - void errorMessageReplacedByErrorCodeUnderConsentOff() { - // Defense-in-depth: even if a provider hands us a raw message under consent-off (a bug in the - // provider, or a third-party provider that doesn't distinguish consent tiers), the hook must - // substitute the ErrorCode name so raw PII from exception messages never reaches the wire. - // Uses a PII-looking marker exactly like the wire-level guards in FlagEvaluationWriterImplTest. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage("For input string: \"jane.doe@datadoghq.com\"") - .flagMetadata(consentOffMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "TYPE_MISMATCH", - captured.get().errorMessage, - "consent-off must replace the raw message with the ErrorCode name"); - assertFalse( - captured.get().errorMessage.contains("jane.doe@datadoghq.com"), - "raw PII must never survive into the enqueued event under consent-off"); - } - - @Test - void errorMessageDroppedWhenConsentOffAndNoErrorCode() { - // Edge case: no ErrorCode available (unusual — providers should set one for ERROR reason). - // Consent-off drops the message and there's nothing to substitute, so the enqueued event has - // no error message at all. This is the strictest privacy-preserving outcome. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorMessage("For input string: \"jane.doe@datadoghq.com\"") - .flagMetadata(consentOffMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().errorMessage); - } - - // ---- test: error code used as fallback message when error message is empty ---- - - @Test - void errorCodeUsedAsFallbackWhenMessageEmpty() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "FLAG_NOT_FOUND", - captured.get().errorMessage, - "error code name must be used when no error message is present"); - } - - // ---- test: success path has no error message ---- - - @Test - void successPathHasNullErrorMessage() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details("ok-flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().errorMessage, "success path must have no error message"); - } - - // ---- test: hook does NO aggregation on the hook thread ---- - - @Test - void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - // Capacity check gates the enqueue; exactly one enqueue call; no start/close. - verify(writer, times(1)).hasCapacityForEnqueue(); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - verify(writer, never()).countPreQueueOverflow(); - verify(writer, never()).countContextTruncated(any()); - verify(writer, never()).close(); - verify(writer, never()).start(); - } - - @Test - void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(false); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - // Pre-queue guard fires: one capacity check, one drop count, and no enqueue at all. - verify(writer, times(1)).hasCapacityForEnqueue(); - verify(writer, times(1)).countPreQueueOverflow(); - verify(writer, never()).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void countContextTruncatedCalledWhenContextIsTruncated() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - // Context with one oversized string value — triggers max_value_length. - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final MutableContext ctx = new MutableContext("user-1"); - ctx.add("oversized", new String(longChars)); - final HookContext hookCtx = - HookContext.builder() - .flagKey("flag") - .type(dev.openfeature.sdk.FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - final FlagEvaluationDetails det = - details( - "flag", - "v", - "v", - dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), - consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - verify(writer, times(1)).countContextTruncated("max_value_length"); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void countContextTruncatedNotCalledWhenNoTruncation() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final MutableContext ctx = new MutableContext("user-1"); - ctx.add("region", "us-east-1"); - final HookContext hookCtx = - HookContext.builder() - .flagKey("flag") - .type(dev.openfeature.sdk.FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - final FlagEvaluationDetails det = - details( - "flag", - "v", - "v", - dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), - consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - verify(writer, never()).countContextTruncated(any()); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void enqueueDisabledIsNoOpBeforeWriterLookup() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - final AtomicReference writerResolved = new AtomicReference<>(false); - final FlagEvalLoggingHook hook = - new FlagEvalLoggingHook<>( - () -> { - writerResolved.set(true); - throw new AssertionError("writer should not be resolved when enqueue is disabled"); - }); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(hookCtxWithTargetingKey("flag", "user-1"), det, Collections.emptyMap()); - - assertFalse(writerResolved.get()); - } - - // ---- test: writer=null -> no-op (killswitch off / not yet started) ---- - - @Test - void writerNullIsNoOp() { - final FlagEvalLoggingHook hook = hookWithWriter(null); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - // Must not throw; nothing is enqueued - hook.finallyAfter(null, det, Collections.emptyMap()); - } - - @Test - void writerLookupLinkageErrorIsNoOp() { - final FlagEvalLoggingHook hook = - new FlagEvalLoggingHook<>( - () -> { - throw new NoSuchMethodError("old bootstrap"); - }); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - assertDoesNotThrow(() -> hook.finallyAfter(null, det, Collections.emptyMap())); - } - - // ---- test: details=null -> no-op ---- - - @Test - void detailsNullIsNoOp() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - // Should not throw - hook.finallyAfter(null, null, Collections.emptyMap()); - - verifyNoInteractions(writer); - } - - // ---- test: targetingKey extracted from evaluation context ---- - - @Test - void targetingKeyExtractedFromContext() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.SPLIT.name(), null); - - final HookContext hookCtx = hookCtxWithTargetingKey("ctx-flag", "user-42"); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "user-42", - captured.get().targetingKey, - "targetingKey must be extracted from the evaluation context"); - } - - @Test - void contextAttributesAreFlattenedAndConvertedInline() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final Map profile = new HashMap<>(); - profile.put("tier", "gold"); - final Map attributes = new HashMap<>(); - attributes.put("score", 42); - attributes.put("profile", profile); - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - context.setTargetingKey("user-42"); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - // Flatten now happens inline in DDEvaluator#copyPrunedContext on the hot path, so the event - // arrives with attrs already populated (no deferred supplier). - final Map attrs = captured.get().attrs; - assertFalse(attrs.isEmpty(), "hook must flatten context inline"); - assertEquals(42, attrs.get("score")); - assertEquals("gold", attrs.get("profile.tier")); - assertFalse(attrs.containsKey("targetingKey")); - assertTrue( - attrs.values().stream().noneMatch(Value.class::isInstance), - "context attrs must contain converted scalar values, not OpenFeature Value wrappers"); - } - - @Test - void contextAttributesUseEnqueueTimeSnapshot() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final MutableContext context = new MutableContext("user-42"); - context.add("region", "us-east-1"); - final MutableStructure profile = new MutableStructure(); - profile.add("tier", "gold"); - context.add("profile", profile); - final List cohorts = new ArrayList<>(); - cohorts.add(Value.objectToValue("beta")); - context.add("cohorts", cohorts); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - context.add("region", "eu-west-1"); - context.add("late", "ignored"); - profile.add("tier", "platinum"); - profile.add("late", "ignored"); - cohorts.set(0, Value.objectToValue("ga")); - cohorts.add(Value.objectToValue("late")); - - assertNotNull(captured.get()); - final Map attrs = captured.get().attrs; - assertEquals("us-east-1", attrs.get("region")); - assertEquals("gold", attrs.get("profile.tier")); - assertEquals("beta", attrs.get("cohorts[0]")); - assertFalse(attrs.containsKey("late")); - assertFalse(attrs.containsKey("profile.late")); - assertFalse(attrs.containsKey("cohorts[1]")); - } - - // ---- observeFullEvaluationData is read from evaluation metadata, never the gateway ---- - - @Test - void readsObserveFullEvaluationDataTrueFromEvaluationMetadata() { - assertTrue(enqueuedEventWithConsentMetadata(true).observeFullEvaluationData); - } - - @Test - void readsObserveFullEvaluationDataFalseFromEvaluationMetadata() { - assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseWhenMetadataAbsent() { - // No metadata at all: fail-closed toward privacy. - assertFalse(enqueuedEventWithConsentMetadata(null).observeFullEvaluationData); - } - - @Test - void protectedPathSkipsEvaluationContextCapture() { - // Consent off → the hook must not snapshot the evaluation context at all. Verified by mutating - // the context after finallyAfter returns and asserting the enqueued event still sees nothing. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final ImmutableMetadata consentOff = - ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .build(); - - hook.finallyAfter( - hookCtx, - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOff), - Collections.emptyMap()); - context.add("region", "eu-west-1"); - - assertNotNull(captured.get()); - assertTrue(captured.get().attrs.isEmpty()); - } - - @Test - void ignoresGatewayConsentEvenWhenItDisagreesWithMetadata() { - // Gateway says on, metadata says off; hook must trust metadata. - FeatureFlaggingGateway.dispatch(observeConfig(true)); - try { - assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); - } finally { - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - } - - /** - * Fires the hook once for a simple targeted evaluation whose metadata carries the given consent - * value ({@code null} = key absent) and returns the enqueued event. - */ - private FlagEvalEvent enqueuedEventWithConsentMetadata(final Boolean consent) { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - final ImmutableMetadata metadata = - consent == null - ? null - : ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, consent) - .build(); - hook.finallyAfter( - hookCtxWithTargetingKey("obs-flag", "user-1"), - details("obs-flag", "on", "on", Reason.TARGETING_MATCH.name(), metadata), - Collections.emptyMap()); - assertNotNull(captured.get(), "writer.enqueue must be called once"); - return captured.get(); - } - - private static ImmutableMetadata consentOnMetadata() { - return ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) - .build(); - } - - private static ImmutableMetadata consentOffMetadata() { - return ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .build(); - } - - private static ServerConfiguration observeConfig(final boolean observeFullEvaluationData) { - return new ServerConfiguration( - "2024-04-17T19:40:53.716Z", - "SERVER", - observeFullEvaluationData, - null, - Collections.emptyMap()); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java deleted file mode 100644 index 322a06d2de7..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; - -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.Reason; -import java.util.Collections; -import org.junit.jupiter.api.Test; - -class FlagEvalMetricsHookTest { - - @Test - void finallyAfterRecordsBasicEvaluation() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value("on-value") - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata( - ImmutableMetadata.builder().addString("allocationKey", "default-alloc").build()) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record( - eq("my-flag"), - eq("on"), - eq(Reason.TARGETING_MATCH.name()), - isNull(), - eq("default-alloc")); - } - - @Test - void finallyAfterRecordsErrorEvaluation() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("missing-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record( - eq("missing-flag"), - isNull(), - eq(Reason.ERROR.name()), - eq(ErrorCode.FLAG_NOT_FOUND), - isNull()); - } - - @Test - void finallyAfterHandlesNullFlagMetadata() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value(true) - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull()); - } - - @Test - void finallyAfterHandlesNullVariantAndReason() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder().flagKey("my-flag").value("default").build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull()); - } - - @Test - void finallyAfterNeverThrows() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - // Should not throw even with completely null inputs - hook.finallyAfter(null, null, null); - - verifyNoInteractions(metrics); - } - - @Test - void finallyAfterIsNoOpWhenMetricsIsNull() { - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(null); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value(true) - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - - // Should not throw - hook.finallyAfter(null, details, Collections.emptyMap()); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java deleted file mode 100644 index 214fadc7274..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; - -import dev.openfeature.sdk.ErrorCode; -import io.opentelemetry.api.common.Attributes; -import io.opentelemetry.api.metrics.LongCounter; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -class FlagEvalMetricsTest { - - @Test - void recordBasicAttributes() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.key", "my-flag"); - assertAttribute(attrs, "feature_flag.result.variant", "on"); - assertAttribute(attrs, "feature_flag.result.reason", "targeting_match"); - assertNoAttribute(attrs, "error.type"); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } - - @Test - void recordErrorAttributes() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("missing-flag", "", "ERROR", ErrorCode.FLAG_NOT_FOUND, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.key", "missing-flag"); - assertAttribute(attrs, "feature_flag.result.variant", ""); - assertAttribute(attrs, "feature_flag.result.reason", "error"); - assertAttribute(attrs, "error.type", "flag_not_found"); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } - - @Test - void recordTypeMismatchError() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "", "ERROR", ErrorCode.TYPE_MISMATCH, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "error.type", "type_mismatch"); - } - - @Test - void recordWithAllocationKey() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", "TARGETING_MATCH", null, "default-allocation"); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.allocation_key", "default-allocation"); - } - - @Test - void recordOmitsEmptyAllocationKey() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", "TARGETING_MATCH", null, ""); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } - - @Test - void recordNullVariantBecomesEmptyString() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", null, "DEFAULT", null, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.variant", ""); - } - - @Test - void recordNullReasonBecomesUnknown() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", null, null, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.reason", "unknown"); - } - - @Test - void recordIsNoOpWhenCounterIsNull() { - FlagEvalMetrics metrics = new FlagEvalMetrics((LongCounter) null); - // Should not throw - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - } - - @Test - void shutdownClearsCounter() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.shutdown(); - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - - verifyNoInteractions(counter); - } - - @Test - void defaultConstructorUsesOpenTelemetryApiOnly() { - try (FlagEvalMetrics metrics = new FlagEvalMetrics()) { - metrics.record("count-flag", "on", "STATIC", null, "default-alloc"); - } - } - - private static void assertAttribute(Attributes attrs, String key, String expected) { - String value = - attrs.asMap().entrySet().stream() - .filter(e -> e.getKey().getKey().equals(key)) - .map(e -> e.getValue().toString()) - .findFirst() - .orElse(null); - if (!expected.equals(value)) { - throw new AssertionError("Expected attribute " + key + "=" + expected + " but got " + value); - } - } - - private static void assertNoAttribute(Attributes attrs, String key) { - boolean present = attrs.asMap().keySet().stream().anyMatch(k -> k.getKey().equals(key)); - if (present) { - throw new AssertionError("Expected no attribute " + key + " but it was present"); - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java deleted file mode 100644 index d7adf645e74..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ /dev/null @@ -1,508 +0,0 @@ -package datadog.trace.api.openfeature; - -import static datadog.trace.api.openfeature.Provider.METADATA; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.openfeature.Provider.Options; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.EventDetails; -import dev.openfeature.sdk.Features; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.ProviderEvent; -import dev.openfeature.sdk.ProviderState; -import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.exceptions.FatalError; -import dev.openfeature.sdk.exceptions.ProviderNotReadyError; -import java.lang.reflect.Field; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -public class ProviderTest { - - private static final long EVENT_TIMEOUT_SECONDS = 10; - - private ExecutorService executor; - - @BeforeEach - public void setup() { - executor = Executors.newSingleThreadExecutor(); - } - - @AfterEach - public void tearDown() { - executor.shutdownNow(); - OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - public void testSetProvider() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final CompletableFuture readyEvent = new CompletableFuture<>(); - api.onProviderReady(readyEvent::complete); - api.setProvider(new Provider()); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @Test - public void testSetProviderAndWait() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Future provider = executor.submit(() -> api.setProviderAndWait(new Provider())); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - provider.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @Test - public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() throws Exception { - final CompletableFuture readyEvent = new CompletableFuture<>(); - final Consumer readyEventHandler = completingHandler(readyEvent); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Client client = api.getClient(); - client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); - - assertThrows( - ProviderNotReadyError.class, - () -> api.setProviderAndWait(new Provider(new Options().initTimeout(10, MILLISECONDS)))); - - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - assertFalse(readyEvent.isDone()); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - - final EventDetails eventDetails = readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - assertThat(eventDetails.getProviderName(), equalTo(METADATA)); - verify(readyEventHandler, times(1)).accept(any()); - } - - @Test - public void testSetProviderAndWaitCompletesWhenConfigurationArrivesAtTimeoutBoundary() - throws Exception { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - return false; - } - - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } - - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } - }; - - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - api.setProviderAndWait(providerRef[0]); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @Test - public void testSetProviderAndWaitFailsWhenConfigurationIsRemovedBeforeInitializationCompletes() { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - hasConfiguration = false; - providerRef[0].onConfigurationChange(); - return true; - } - - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } - - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } - }; - - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - - assertThrows(ProviderNotReadyError.class, () -> api.setProviderAndWait(providerRef[0])); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - } - - @Test - public void testInitializationErrorDoesNotOverwriteRecoveredReadyState() throws Exception { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - hasConfiguration = false; - providerRef[0].onConfigurationChange(); - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - throw new ProviderNotReadyError( - "Provider timed-out while waiting for initial configuration"); - } - - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } - - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } - }; - - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - - assertThrows(ProviderNotReadyError.class, () -> providerRef[0].initialize(null)); - - assertThat(initializationState(providerRef[0]), equalTo("READY")); - } - - @Test - public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - api.setProviderAndWait(new Provider()); - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - - final CompletableFuture errorEvent = new CompletableFuture<>(); - final CompletableFuture readyEvent = new CompletableFuture<>(); - final CompletableFuture configChangedEvent = new CompletableFuture<>(); - final Consumer errorEventHandler = completingHandler(errorEvent); - final Consumer readyEventHandler = completingHandler(readyEvent); - final Consumer configChangedEventHandler = completingHandler(configChangedEvent); - client.on(ProviderEvent.PROVIDER_ERROR, errorEventHandler); - client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEventHandler); - - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - final EventDetails eventDetails = errorEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - assertThat(eventDetails.getProviderName(), equalTo(METADATA)); - - final FlagEvaluationDetails evalDetails = client.getStringDetails("missing", "default"); - assertThat(evalDetails.getValue(), equalTo("default")); - assertThat(evalDetails.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - - client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - configChangedEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - verify(errorEventHandler, times(1)).accept(any()); - verify(readyEventHandler, times(1)).accept(any()); - verify(configChangedEventHandler, times(1)).accept(any()); - } - - @Test - public void testFailureToLoadInternalApi() { - @SuppressWarnings("unchecked") - final Consumer consumer = mock(Consumer.class); - - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.onProviderError(consumer); - - assertThrows( - FatalError.class, - () -> - api.setProviderAndWait( - new Provider() { - @Override - protected Class loadEvaluatorClass() throws ClassNotFoundException { - throw new ClassNotFoundException( - "Class " + FeatureFlaggingGateway.class.getName() + " not found"); - } - })); - } - - @Test - public void testGetProviderHooksReturnsFlagEvalMetricsHook() { - Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); - List hooks = provider.getProviderHooks(); - // Two hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) - assertThat(hooks.size(), equalTo(2)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); - 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 OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - - final FlagEvaluationDetails details = - api.getClient().getStringDetails("logged-flag", "default", context); - - assertThat(details.getValue(), equalTo("value")); - final FlagEvalEvent event = captured.get(); - assertThat(event.flagKey, equalTo("logged-flag")); - assertThat(event.variant, equalTo("variant-1")); - assertThat(event.allocationKey, equalTo("allocation-1")); - assertThat(event.targetingKey, equalTo("user-1")); - assertThat(event.evalTimeMs, equalTo(1_700_000_000_000L)); - assertThat(event.attrs.get("region"), equalTo("us-east-1")); - } - - @Test - public void testGetProviderHooksReturnsFlagEvalMetricsHookWithAndWithoutSpanEnrichment() { - final Evaluator evaluator = mock(Evaluator.class); - final Provider providerWithoutSpanEnrichment = - new Provider(new Options(), evaluator, Boolean.FALSE); - final Provider providerWithSpanEnrichment = - new Provider(new Options(), evaluator, Boolean.TRUE); - - assertHasFlagEvalMetricsHook(providerWithoutSpanEnrichment); - assertHasFlagEvalMetricsHook(providerWithSpanEnrichment); - } - - @Test - public void testShutdownCleansUpEvaluator() throws Exception { - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - final Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator, Boolean.FALSE); - - provider.initialize(null); - provider.shutdown(); - - verify(evaluator).shutdown(); - // After shutdown, getProviderHooks still returns a list with both OTel + logging hooks - assertThat(provider.getProviderHooks().size(), equalTo(2)); - } - - private static void assertHasFlagEvalMetricsHook(final Provider provider) { - assertTrue( - provider.getProviderHooks().stream().anyMatch(FlagEvalMetricsHook.class::isInstance), - "flag evaluation metrics hook should be registered"); - } - - public interface EvaluateMethod { - FlagEvaluationDetails evaluate(Features client, String flag, E defaultValue); - } - - private static Arguments[] providerMethods() { - return new Arguments[] { - Arguments.of("bool", false, (EvaluateMethod) Features::getBooleanDetails), - Arguments.of("string", "Hello!", (EvaluateMethod) Features::getStringDetails), - Arguments.of("int", 23, (EvaluateMethod) Features::getIntegerDetails), - Arguments.of("double", 3.14D, (EvaluateMethod) Features::getDoubleDetails), - Arguments.of("object", new Value(), (EvaluateMethod) Features::getObjectDetails) - }; - } - - @MethodSource("providerMethods") - @ParameterizedTest - public void testProviderEvaluation( - final String flag, final E defaultValue, final EvaluateMethod method) throws Exception { - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - when(evaluator.evaluate(any(), any(), any(), any())) - .thenAnswer( - invocation -> - ProviderEvaluation.builder() - .value(invocation.getArgument(2)) - .reason("MOCK") - .build()); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); - final Client client = api.getClient(); - final FlagEvaluationDetails result = method.evaluate(client, flag, defaultValue); - assertThat(result.getValue(), equalTo(defaultValue)); - assertThat(result.getReason(), equalTo("MOCK")); - verify(evaluator, times(1)).initialize(eq(10L), eq(SECONDS), any()); - verify(evaluator, times(1)) - .evaluate(any(), eq(flag), eq(defaultValue), any(EvaluationContext.class)); - } - - private static String initializationState(final Provider provider) throws Exception { - final Field stateField = Provider.class.getDeclaredField("initializationState"); - stateField.setAccessible(true); - final AtomicReference state = (AtomicReference) stateField.get(provider); - return state.get().toString(); - } - - @SuppressWarnings("unchecked") - private static Consumer completingHandler( - final CompletableFuture event) { - final Consumer handler = mock(Consumer.class); - doAnswer( - invocation -> { - event.complete(invocation.getArgument(0)); - return null; - }) - .when(handler) - .accept(any()); - return handler; - } - - private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - }; - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java deleted file mode 100644 index 2ce0931e116..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java +++ /dev/null @@ -1,241 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.ImmutableStructure; -import dev.openfeature.sdk.Value; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Capture-side unit suite for APM feature-flag span enrichment. The hook dispatches a {@link - * SpanEnrichmentEvent} onto {@link FeatureFlaggingGateway} and the agent-side write tier does the - * accumulation, so these tests assert the dispatched events (not span tags — those are covered in - * {@code feature-flagging-lib}) plus the {@link Provider} gating. - */ -class SpanEnrichmentHookTest { - - private final List captured = new ArrayList<>(); - private final FeatureFlaggingGateway.SpanEnrichmentListener listener = captured::add; - - @BeforeEach - void register() { - FeatureFlaggingGateway.addSpanEnrichmentListener(listener); - } - - @AfterEach - void deregister() { - FeatureFlaggingGateway.removeSpanEnrichmentListener(listener); - } - - // ---- helpers ---- - - private static FlagEvaluationDetails details( - final String flagKey, - final String variant, - final Object value, - final ImmutableMetadata metadata) { - return FlagEvaluationDetails.builder() - .flagKey(flagKey) - .variant(variant) - .value(value) - .flagMetadata(metadata) - .build(); - } - - private static ImmutableMetadata metadata(final Integer serialId, final boolean doLog) { - final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); - if (serialId != null) { - builder.addInteger(SpanEnrichmentHook.METADATA_SERIAL_ID, serialId); - } - builder.addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, doLog); - return builder.build(); - } - - private static HookContext ctx(final String flagKey, final String targetingKey) { - return HookContext.from( - flagKey, - FlagValueType.STRING, - null, - null, - targetingKey == null ? new ImmutableContext() : new ImmutableContext(targetingKey), - "default"); - } - - // ---- serial-id branch ---- - - @Test - void serialIdWithDoLogDispatchesSerialAndSubject() { - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), - details("flag", "on", "v", metadata(42, true)), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertTrue(event.hasSerialId()); - assertEquals(42, event.serialId()); - assertTrue(event.doLog()); - assertEquals("user-1", event.targetingKey()); - } - - @Test - void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), - details("flag", "on", "v", metadata(7, false)), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertTrue(event.hasSerialId()); - assertEquals(7, event.serialId()); - assertFalse( - event.doLog(), "doLog=false must be carried through so the write side skips subject"); - } - - @Test - void wrongTypedSerialIdDispatchesNothing() { - // Defensive: a non-integer value under the serial-id key (wrong type) is ignored, not crashed. - final ImmutableMetadata bad = - ImmutableMetadata.builder() - .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") - .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) - .build(); - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), details("flag", "on", "v", bad), Collections.emptyMap()); - assertTrue(captured.isEmpty(), "a wrong-typed serial id must never break eval or dispatch"); - } - - // ---- runtime-default branch (missing variant) ---- - - @Test - void missingVariantDispatchesRuntimeDefaultWithNativeMap() { - final Map objectValue = Collections.singletonMap("k", "val"); - new SpanEnrichmentHook() - .finallyAfter( - ctx("obj-flag", "user-1"), - details("obj-flag", null, objectValue, ImmutableMetadata.builder().build()), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertFalse(event.hasSerialId()); - assertEquals("obj-flag", event.flagKey()); - assertEquals( - objectValue, event.defaultValue(), "a native map default passes through unchanged"); - } - - @Test - void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { - final Map inner = new LinkedHashMap<>(); - inner.put("enabled", new Value(true)); - final Value structureDefault = new Value(new ImmutableStructure(inner)); - - new SpanEnrichmentHook() - .finallyAfter( - ctx("struct-flag", "user-1"), - details("struct-flag", null, structureDefault, ImmutableMetadata.builder().build()), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final Object value = captured.get(0).defaultValue(); - // Crucially a native Map, NOT an OpenFeature Value — the seam carries only JDK types. - final Map asMap = assertInstanceOf(Map.class, value); - assertEquals(Boolean.TRUE, asMap.get("enabled")); - } - - // ---- unwrapDefaultValue (the Value -> native conversion) ---- - - @Test - void unwrapConvertsValueScalarsToNative() { - assertEquals("hello", SpanEnrichmentHook.unwrapDefaultValue(new Value("hello"))); - assertEquals(Boolean.TRUE, SpanEnrichmentHook.unwrapDefaultValue(new Value(true))); - assertEquals(7, SpanEnrichmentHook.unwrapDefaultValue(new Value(7))); - assertNull(SpanEnrichmentHook.unwrapDefaultValue(new Value())); - } - - @Test - void unwrapConvertsValueListToNativeList() { - final Value listDefault = - new Value(Arrays.asList(new Value("a"), new Value(2), new Value(true))); - final Object out = SpanEnrichmentHook.unwrapDefaultValue(listDefault); - assertEquals(Arrays.asList("a", 2, Boolean.TRUE), out); - } - - @Test - void unwrapPassesNativeValuesThrough() { - final Map native0 = Collections.singletonMap("a", "b"); - assertEquals(native0, SpanEnrichmentHook.unwrapDefaultValue(native0)); - assertEquals("plain", SpanEnrichmentHook.unwrapDefaultValue("plain")); - assertNull(SpanEnrichmentHook.unwrapDefaultValue(null)); - } - - // ---- error isolation ---- - - @Test - void nullDetailsDispatchesNothing() { - new SpanEnrichmentHook().finallyAfter(null, null, null); - assertTrue(captured.isEmpty()); - } - - // ---- Provider gating ---- - - @Test - void gateOffConstructsNoHook() { - final Provider provider = new Provider(new Provider.Options(), null, Boolean.FALSE); - assertNull(provider.spanEnrichmentHook(), "gate off => no span-enrichment hook"); - for (final Hook hook : provider.getProviderHooks()) { - assertFalse( - hook instanceof SpanEnrichmentHook, "gate off => SpanEnrichmentHook never registered"); - } - } - - @Test - void gateOnConstructsHookAndRegistersItInProviderHooks() { - final Provider provider = new Provider(new Provider.Options(), null, Boolean.TRUE); - assertTrue(provider.spanEnrichmentHook() != null, "gate on => hook constructed"); - boolean registered = false; - for (final Hook hook : provider.getProviderHooks()) { - if (hook instanceof SpanEnrichmentHook) { - registered = true; - } - } - assertTrue(registered, "gate on => SpanEnrichmentHook registered in getProviderHooks"); - } - - @Test - void getProviderHooksReturnsSameInstanceEachCall() { - final Provider gateOff = new Provider(new Provider.Options(), null, Boolean.FALSE); - assertTrue( - gateOff.getProviderHooks() == gateOff.getProviderHooks(), - "gate off => getProviderHooks allocates nothing (same instance)"); - - final Provider gateOn = new Provider(new Provider.Options(), null, Boolean.TRUE); - assertTrue( - gateOn.getProviderHooks() == gateOn.getProviderHooks(), - "gate on => getProviderHooks allocates nothing (same instance)"); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts b/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts index 94f08ae55fe..3a60a4599da 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts @@ -4,30 +4,7 @@ plugins { id("dd-trace-java.module.bootstrap-component") } -description = "Feature flagging remote common module (bootstrap classloader)" - -extra["excludedClassesCoverage"] = listOf( - // Feature lags POJOs - "datadog.trace.api.featureflag.exposure.Allocation", - "datadog.trace.api.featureflag.exposure.ExposureEvent", - "datadog.trace.api.featureflag.exposure.ExposuresRequest", - "datadog.trace.api.featureflag.exposure.Flag", - "datadog.trace.api.featureflag.exposure.Subject", - "datadog.trace.api.featureflag.exposure.Variant", - "datadog.trace.api.featureflag.ufc.v1.Allocation", - "datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration", - "datadog.trace.api.featureflag.ufc.v1.ConditionOperator", - "datadog.trace.api.featureflag.ufc.v1.Environment", - "datadog.trace.api.featureflag.ufc.v1.Feature", - "datadog.trace.api.featureflag.ufc.v1.Flag", - "datadog.trace.api.featureflag.ufc.v1.Rule", - "datadog.trace.api.featureflag.ufc.v1.ServerConfiguration", - "datadog.trace.api.featureflag.ufc.v1.Shard", - "datadog.trace.api.featureflag.ufc.v1.ShardRange", - "datadog.trace.api.featureflag.ufc.v1.Split", - "datadog.trace.api.featureflag.ufc.v1.ValueType", - "datadog.trace.api.featureflag.ufc.v1.Variant", -) +description = "Feature flagging bridge between the SDK instrumentation and the agent (bootstrap classloader)" dependencies { testImplementation(libs.bundles.junit5) diff --git a/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile b/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile index f6a80637793..425897f716a 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile @@ -30,7 +30,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath org.codehaus.groovy:groovy-ant:3.0.23=codenarc org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java index 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/Allocation.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java deleted file mode 100644 index 53df4b4b512..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -public class Allocation { - public final String key; - - public Allocation(final String key) { - this.key = key; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java deleted file mode 100644 index 4101b767760..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java +++ /dev/null @@ -1,36 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -public class ExposureEvent { - // milliseconds since epoch as given by System.currentTimeMillis() - public final long timestamp; - public final Allocation allocation; - public final Flag flag; - public final Variant variant; - public final Subject subject; - - public final Integer serial_id; - - public ExposureEvent( - final long timestamp, - final Allocation allocation, - final Flag flag, - final Variant variant, - final Subject subject) { - this(timestamp, allocation, flag, variant, subject, null); - } - - public ExposureEvent( - final long timestamp, - final Allocation allocation, - final Flag flag, - final Variant variant, - final Subject subject, - final Integer serialId) { - this.timestamp = timestamp; - this.allocation = allocation; - this.flag = flag; - this.variant = variant; - this.subject = subject; - this.serial_id = serialId; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java deleted file mode 100644 index 3b25028c245..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -import java.util.List; -import java.util.Map; - -public class ExposuresRequest { - - public final Map context; - public final List exposures; - - public ExposuresRequest(final Map context, final List exposures) { - this.context = context; - this.exposures = exposures; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java deleted file mode 100644 index c13b123ad6c..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -public class Flag { - public final String key; - - public Flag(final String key) { - this.key = key; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java deleted file mode 100644 index ebd381e7069..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java +++ /dev/null @@ -1,13 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -import java.util.Map; - -public class Subject { - public final String id; - public final Map attributes; - - public Subject(final String id, final Map attributes) { - this.id = id; - this.attributes = attributes; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java deleted file mode 100644 index dfc4cbe38c7..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -public class Variant { - public final String key; - - public Variant(final String key) { - this.key = key; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java deleted file mode 100644 index f5f44b94d7b..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java +++ /dev/null @@ -1,98 +0,0 @@ -package datadog.trace.api.featureflag.flagevaluation; - -import java.util.Collections; -import java.util.Map; - -/** - * Lightweight data record capturing a single flag evaluation for EVP flagevaluation emission. - * - *

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

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

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

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

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

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

{@code start} and {@code end} store unsigned 32-bit wire values in {@code int} fields to - * preserve this bootstrap model's binary compatibility. Consumers must use {@link - * Integer#toUnsignedLong(int)} before comparing them. - */ -public class ShardRange { - public final int start; - public final int end; - - public ShardRange(final int start, final int end) { - this.start = start; - this.end = end; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java deleted file mode 100644 index 5e98f90dabc..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java +++ /dev/null @@ -1,38 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.List; -import java.util.Map; - -public class Split { - public final List shards; - public final String variationKey; - public final Map extraLogging; - // Nullable Integer (not primitive int): the serialId is absent in some UFC shapes. Populated by - // Moshi reflective deserialization from the UFC "serialId" JSON field. Surfaced as - // __dd_split_serial_id in eval metadata for APM span enrichment. - public final Integer serialId; - // Null when the UFC split has no features. A provider must check that this field exists before - // reading it, because an older agent ships a Split without it. - public final List features; - - public Split( - final List shards, - final String variationKey, - final Map extraLogging, - final Integer serialId) { - this(shards, variationKey, extraLogging, serialId, null); - } - - public Split( - final List shards, - final String variationKey, - final Map extraLogging, - final Integer serialId, - final List features) { - this.shards = shards; - this.variationKey = variationKey; - this.extraLogging = extraLogging; - this.serialId = serialId; - this.features = features; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java deleted file mode 100644 index 6f0a973a379..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -public enum ValueType { - BOOLEAN, - INTEGER, - NUMERIC, - STRING, - JSON -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java deleted file mode 100644 index 26e30aa1aad..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java +++ /dev/null @@ -1,11 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -public class Variant { - public final String key; - public final Object value; - - public Variant(final String key, final Object value) { - this.key = key; - this.value = value; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java index 887a153f0a1..73bf6c24170 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java @@ -1,115 +1,25 @@ package datadog.trace.api.featureflag; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class FeatureFlaggingGatewayTest { - - private FeatureFlaggingGateway.ConfigListener configListener; - private FeatureFlaggingGateway.ActivationListener activationListener; - private FeatureFlaggingGateway.ExposureListener exposureListener; - private FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener; - private ServerConfiguration firstConfiguration; - private ServerConfiguration secondConfiguration; - private ExposureEvent firstExposure; - private ExposureEvent secondExposure; - - @BeforeEach - void setUp() { - configListener = mock(FeatureFlaggingGateway.ConfigListener.class); - activationListener = mock(FeatureFlaggingGateway.ActivationListener.class); - exposureListener = mock(FeatureFlaggingGateway.ExposureListener.class); - spanEnrichmentListener = mock(FeatureFlaggingGateway.SpanEnrichmentListener.class); - firstConfiguration = mock(ServerConfiguration.class); - secondConfiguration = mock(ServerConfiguration.class); - firstExposure = mock(ExposureEvent.class); - secondExposure = mock(ExposureEvent.class); - } - @AfterEach void tearDown() { - FeatureFlaggingGateway.removeConfigListener(configListener); - FeatureFlaggingGateway.removeActivationListener(activationListener); - FeatureFlaggingGateway.removeExposureListener(exposureListener); - FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - void testProviderActivationListener() { - FeatureFlaggingGateway.addActivationListener(activationListener); - - FeatureFlaggingGateway.activate(); - - verify(activationListener).activate(); - verifyNoMoreInteractions(activationListener); - } - - @Test - void testAttachingAConfigListener() { - clearCurrentServerConfiguration(); - - FeatureFlaggingGateway.addConfigListener(configListener); - FeatureFlaggingGateway.dispatch(firstConfiguration); - - verify(configListener).accept(firstConfiguration); - verifyNoMoreInteractions(configListener); - - FeatureFlaggingGateway.dispatch(secondConfiguration); - - verify(configListener).accept(secondConfiguration); - verifyNoMoreInteractions(configListener); + FeatureFlaggingGateway.register(null); } @Test - void testAttachingAListenerAfterConfigured() { - FeatureFlaggingGateway.dispatch(firstConfiguration); - FeatureFlaggingGateway.addConfigListener(configListener); - - verify(configListener).accept(firstConfiguration); - verifyNoMoreInteractions(configListener); - } - - @Test - void testAttachingAnExposureListener() { - FeatureFlaggingGateway.addExposureListener(exposureListener); - FeatureFlaggingGateway.dispatch(firstExposure); - - verify(exposureListener).accept(firstExposure); - verifyNoMoreInteractions(exposureListener); - - FeatureFlaggingGateway.dispatch(secondExposure); - - verify(exposureListener).accept(secondExposure); - verifyNoMoreInteractions(exposureListener); - } - - @Test - void testAttachingASpanEnrichmentListener() { - final SpanEnrichmentEvent firstEvent = SpanEnrichmentEvent.serialId(42, true, "user-1"); - final SpanEnrichmentEvent secondEvent = SpanEnrichmentEvent.runtimeDefault("flag", "value"); - - FeatureFlaggingGateway.addSpanEnrichmentListener(spanEnrichmentListener); - FeatureFlaggingGateway.dispatch(firstEvent); - - verify(spanEnrichmentListener).accept(firstEvent); - verifyNoMoreInteractions(spanEnrichmentListener); - - FeatureFlaggingGateway.dispatch(secondEvent); - - verify(spanEnrichmentListener).accept(secondEvent); - verifyNoMoreInteractions(spanEnrichmentListener); - } - - private static void clearCurrentServerConfiguration() { - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + void registersAndUnregistersTheBackend() { + assertNull(FeatureFlaggingGateway.backend()); + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + assertSame(backend, FeatureFlaggingGateway.backend()); + FeatureFlaggingGateway.register(null); + assertNull(FeatureFlaggingGateway.backend()); } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java deleted file mode 100644 index 55e7a8943ac..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package datadog.trace.api.featureflag; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import org.junit.jupiter.api.Test; - -class SpanEnrichmentEventTest { - - @Test - void serialIdEventCarriesItsFields() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(42, true, "user-1"); - - assertTrue(event.hasSerialId()); - assertEquals(42, event.serialId()); - assertTrue(event.doLog()); - assertEquals("user-1", event.targetingKey()); - assertNull(event.flagKey()); - assertNull(event.defaultValue()); - } - - @Test - void serialIdEventWithoutDoLogOrTargetingKey() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(7, false, null); - - assertTrue(event.hasSerialId()); - assertEquals(7, event.serialId()); - assertFalse(event.doLog()); - assertNull(event.targetingKey()); - } - - @Test - void runtimeDefaultEventCarriesItsFields() { - final Object value = Collections.singletonMap("k", "v"); - final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", value); - - assertFalse(event.hasSerialId()); - assertEquals("flag", event.flagKey()); - assertEquals(value, event.defaultValue()); - assertEquals(0, event.serialId()); - assertFalse(event.doLog()); - assertNull(event.targetingKey()); - } - - @Test - void runtimeDefaultEventAllowsNullValue() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", null); - - assertFalse(event.hasSerialId()); - assertEquals("flag", event.flagKey()); - assertNull(event.defaultValue()); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java deleted file mode 100644 index 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-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java deleted file mode 100644 index e3d78ac4b86..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java +++ /dev/null @@ -1,220 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class ParsedSemverTest { - - // --- Parse tests (ported from Go semver_test.go TestParseSemver) --- - - static Stream validVersions() { - return Stream.of( - Arguments.of("0.0.0", 0L, 0L, 0L, ""), - Arguments.of( - "18446744073709551615.18446744073709551615.18446744073709551615", - Long.parseUnsignedLong("18446744073709551615"), - Long.parseUnsignedLong("18446744073709551615"), - Long.parseUnsignedLong("18446744073709551615"), - ""), - Arguments.of("1.2.3-alpha.1", 1L, 2L, 3L, "alpha.1"), - Arguments.of("1.2.3-18446744073709551616", 1L, 2L, 3L, "18446744073709551616"), - Arguments.of("1.2.3+build.001", 1L, 2L, 3L, ""), - Arguments.of("1.2.3-alpha-1+build.001", 1L, 2L, 3L, "alpha-1")); - } - - @ParameterizedTest(name = "valid: {0}") - @MethodSource("validVersions") - void testParseValid( - final String version, - final long major, - final long minor, - final long patch, - final String prerelease) { - final ParsedSemver parsed = ParsedSemver.parse(version); - assertTrue(parsed != null, "expected " + version + " to parse"); - assertEquals(major, parsed.getMajor()); - assertEquals(minor, parsed.getMinor()); - assertEquals(patch, parsed.getPatch()); - assertEquals(prerelease, parsed.getPrerelease()); - } - - static Stream invalidVersions() { - return Stream.of( - "", - "x", - "v1.2.3", - "01.2.3", - "1.02.3", - "1.2.03", - "18446744073709551616.0.0", - "0.18446744073709551616.0", - "0.0.18446744073709551616", - "1.2.3-", - "1.2.3+", - "1.2.3-alpha..1", - "1.2.3+build..1", - "1.2.3-01", - "1.2.3-alpha_1", - "1.2.3-alpha+build+other", - "1.2.3-α", - " 1.2.3", - "1.2.3 "); - } - - @ParameterizedTest(name = "invalid: {0}") - @MethodSource("invalidVersions") - void testParseInvalid(final String version) { - assertNull(ParsedSemver.parse(version), "expected " + version + " to be invalid"); - } - - // --- Compare tests (ported from Go semver_test.go TestCompareSemver) --- - - /** The canonical SemVer precedence ordering from the spec. */ - private static final String[] ORDERED_VERSIONS = { - "1.0.0-alpha", - "1.0.0-alpha.1", - "1.0.0-alpha.beta", - "1.0.0-beta", - "1.0.0-beta.2", - "1.0.0-beta.11", - "1.0.0-rc.1", - "1.0.0", - "1.0.1", - "1.1.0", - "2.0.0", - }; - - @Test - void normalizesMissingCorePartsAndComparesExtendedCoreParts() { - assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18"), ParsedSemver.parse("18.0.0"))); - assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18.0"), ParsedSemver.parse("18.0.0"))); - assertEquals( - 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0"))); - assertEquals( - 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0.0"))); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4"), ParsedSemver.parse("1.2.3.5")) < 0); - assertTrue(ParsedSemver.parse("1.2.3.4.5.6") != null); - assertTrue(ParsedSemver.parse("1.2.3.4.5.6.7") != null); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5"), ParsedSemver.parse("1.2.3.4.6")) < 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5.6"), ParsedSemver.parse("1.2.3.4.5.7")) - < 0); - assertTrue( - ParsedSemver.compare( - ParsedSemver.parse("1.2.3.4.5.6.7"), ParsedSemver.parse("1.2.3.4.5.6.8")) - < 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("18.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("18.0.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); - } - - @Test - void testCompareSemverOrdering() { - for (int i = 0; i < ORDERED_VERSIONS.length; i++) { - final ParsedSemver left = ParsedSemver.parse(ORDERED_VERSIONS[i]); - assertTrue(left != null, "left parse failed: " + ORDERED_VERSIONS[i]); - for (int j = 0; j < ORDERED_VERSIONS.length; j++) { - final ParsedSemver right = ParsedSemver.parse(ORDERED_VERSIONS[j]); - assertTrue(right != null, "right parse failed: " + ORDERED_VERSIONS[j]); - final int ordering = ParsedSemver.compare(left, right); - if (i < j) { - assertTrue(ordering < 0, ORDERED_VERSIONS[i] + " should precede " + ORDERED_VERSIONS[j]); - } else if (i > j) { - assertTrue(ordering > 0, ORDERED_VERSIONS[i] + " should follow " + ORDERED_VERSIONS[j]); - } else { - assertEquals(0, ordering); - } - } - } - } - - @Test - void testCompareSemverArbitrarilyLargeNumericPrerelease() { - final ParsedSemver left = ParsedSemver.parse("1.0.0-99999999999999999999"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0-100000000000000000000"); - assertTrue(right != null); - assertTrue(ParsedSemver.compare(left, right) < 0); - } - - @Test - void testValueObjectMethods() { - final ParsedSemver value = ParsedSemver.parse("1.2.3-alpha"); - final ParsedSemver equalValue = ParsedSemver.parse("1.2.3-alpha"); - final ParsedSemver release = ParsedSemver.parse("1.2.3"); - assertTrue(value != null); - assertTrue(equalValue != null); - assertTrue(release != null); - assertTrue(value.equals(value)); - assertTrue(value.equals(equalValue)); - assertFalse(value.equals(null)); - assertFalse(value.equals("1.2.3-alpha")); - assertEquals(value.hashCode(), equalValue.hashCode()); - assertEquals("1.2.3-alpha", value.toString()); - assertEquals("1.2.3", release.toString()); - } - - @Test - void testCompareSemverNumericPrereleaseIdentifiersLexicographically() { - final ParsedSemver left = ParsedSemver.parse("1.0.0-10"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0-11"); - assertTrue(right != null); - assertTrue(ParsedSemver.compare(left, right) < 0); - } - - @Test - void testEqualsAndHashCodeNormalizeTrailingZeroComponents() { - // Versions that compare as equal must also be equals() and share a hashCode, even when they - // differ only by trailing zero components (1.2.3 vs 1.2.3.0 vs 1.2.3.0.0). - final ParsedSemver base = ParsedSemver.parse("1.2.3"); - final ParsedSemver trailingZero = ParsedSemver.parse("1.2.3.0"); - final ParsedSemver trailingZeros = ParsedSemver.parse("1.2.3.0.0"); - assertTrue(base != null); - assertTrue(trailingZero != null); - assertTrue(trailingZeros != null); - assertEquals(0, ParsedSemver.compare(base, trailingZero)); - assertEquals(0, ParsedSemver.compare(base, trailingZeros)); - assertEquals(base, trailingZero); - assertEquals(base, trailingZeros); - assertEquals(trailingZero, trailingZeros); - assertEquals(base.hashCode(), trailingZero.hashCode()); - assertEquals(base.hashCode(), trailingZeros.hashCode()); - assertEquals(trailingZero.hashCode(), trailingZeros.hashCode()); - - // All-zero versions of varying length are also compare-equal and must hash identically. - final ParsedSemver zero = ParsedSemver.parse("0"); - final ParsedSemver zeroZero = ParsedSemver.parse("0.0.0.0"); - assertTrue(zero != null); - assertTrue(zeroZero != null); - assertEquals(0, ParsedSemver.compare(zero, zeroZero)); - assertEquals(zero, zeroZero); - assertEquals(zero.hashCode(), zeroZero.hashCode()); - - // Distinct versions must remain unequal and may differ in hash. - final ParsedSemver release = ParsedSemver.parse("1.2.4"); - assertTrue(release != null); - assertNotEquals(base, release); - } - - @Test - void testCompareSemverBuildMetadataIsIgnored() { - final ParsedSemver left = ParsedSemver.parse("1.0.0+build.1"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0+build.2"); - assertTrue(right != null); - assertEquals(0, ParsedSemver.compare(left, right)); - } -} diff --git a/products/feature-flagging/feature-flagging-config/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 c772051e389..785412e787d 100644 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java @@ -1,5 +1,10 @@ package datadog.trace.api.featureflag.config; +/** + * The Feature Flags settings the agent needs: the product switch and the configuration source. The + * {@code dd-openfeature} SDK resolves them with the same semantics, see {@code + * ConfigurationResolutionParityTest} in the {@code dd-openfeature-connector} instrumentation. + */ public class FeatureFlaggingConfig { public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; @@ -9,28 +14,8 @@ 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"; + + private FeatureFlaggingConfig() {} } diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 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 580a1c9f00c..79a810ea0c1 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/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java deleted file mode 100644 index bbec5d65229..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.communication.BackendApiFactory; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.intake.Intake; -import de.thetaphi.forbiddenapis.SuppressForbidden; -import java.util.HashMap; -import java.util.Map; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Group; -import org.openjdk.jmh.annotations.GroupThreads; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; -import org.openjdk.jmh.infra.Blackhole; - -/** - * Producer-contention benchmark for {@link FlagEvaluationWriterImpl#enqueue}. - * - *

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

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

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

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

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

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

Run: {@code ./gradlew :products:feature-flagging:feature-flagging-lib:jmh - * -PjmhIncludes=FlagEvaluationHotPathBenchmark}. - */ -@State(Scope.Benchmark) -@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(NANOSECONDS) -@Fork(value = 1) -public class FlagEvaluationHotPathBenchmark { - - @Param({ - "typical/100flags_50users_10fields", - "stress/10flags_1000users_250fields", - "scale/2500flags_500users_20fields" - }) - public String profile; - - private Map attrs; - private String[] flagKeys; - private String[] targetingKeys; - private int cursor; - private FlagEvaluationWriterImpl writer; - private FlagEvaluationWriterImpl.SerializingHandlerForTest handler; - - @Setup(Level.Iteration) - public void setUp() { - final Profile p = Profile.fromName(profile); - - attrs = new HashMap<>(); - for (int i = 0; i < p.numFields; i++) { - attrs.put("field" + i, "value"); - } - flagKeys = keys("bench-flag-", p.numFlags); - targetingKeys = keys("bench-user-", p.numUsers); - cursor = 0; - - final Config config = Config.get(); - final BackendApiFactory factory = new BackendApiFactory(config, null); - final Map ddContext = new HashMap<>(); - ddContext.put("service", "bench-service"); - handler = - FlagEvaluationWriterImpl.createHandlerForTest( - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), ddContext); - - // Capacity large enough that the benchmark never overflows within a measurement window. - writer = - new FlagEvaluationWriterImpl( - 1 << 20, - Long.MAX_VALUE, - NANOSECONDS, - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), - config); - } - - /** - * Queue-mechanics cost only: event allocation plus the non-blocking bounded-queue offer. Excludes - * the hook's inline context snapshot - see the class javadoc. - */ - @Benchmark - public void writerEnqueue(final Blackhole blackhole) { - final FlagEvalEvent event = nextEvent(); - writer.enqueue(event); - blackhole.consume(writer.pollQueuedEventForTest()); - blackhole.consume(event); - } - - /** Worker-thread cost: materialize context, prune, canonicalize, and aggregate. */ - @Benchmark - public void workerAggregate(final Blackhole blackhole) { - final FlagEvalEvent event = nextEvent(); - handler.aggregateEvent(event); - if ((cursor % 10_000) == 0) { - handler.clearAggregationForTest(); - } - blackhole.consume(handler.fullTierSizeForTest()); - } - - private FlagEvalEvent nextEvent() { - final int i = cursor++; - // observeFullEvaluationData=true is required for this benchmark to mean anything: under the - // consent-off default the aggregator drops attrs and skips canonicalContextKey entirely, so - // every field-count profile would collapse to the same scalar-only cost. - return new FlagEvalEvent( - flagKeys[Math.floorMod(i, flagKeys.length)], - "variant-" + Math.floorMod(i, 4), - "alloc-" + Math.floorMod(i, flagKeys.length), - targetingKeys[Math.floorMod(i, targetingKeys.length)], - null, - 1_700_000_000_000L + i, - true, - attrs); - } - - private static String[] keys(final String prefix, final int count) { - final String[] out = new String[count]; - for (int i = 0; i < count; i++) { - out[i] = prefix + i; - } - return out; - } - - private static final class Profile { - private final int numFlags; - private final int numUsers; - private final int numFields; - - private Profile(final int numFlags, final int numUsers, final int numFields) { - this.numFlags = numFlags; - this.numUsers = numUsers; - this.numFields = numFields; - } - - private static Profile fromName(final String name) { - if ("typical/100flags_50users_10fields".equals(name)) { - return new Profile(100, 50, 10); - } - if ("stress/10flags_1000users_250fields".equals(name)) { - return new Profile(10, 1_000, 250); - } - if ("scale/2500flags_500users_20fields".equals(name)) { - return new Profile(2_500, 500, 20); - } - throw new IllegalArgumentException("unknown benchmark profile: " + name); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java deleted file mode 100644 index f4fc35cce30..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java +++ /dev/null @@ -1,489 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.communication.http.OkHttpUtils.prepareRequest; -import static datadog.communication.http.OkHttpUtils.sendWithRetries; -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_CONFIGURATION_POLLER; -import static datadog.trace.util.Strings.isBlank; - -import datadog.communication.http.HttpRetryPolicy; -import datadog.communication.http.OkHttpUtils; -import datadog.logging.RatelimitedLogger; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.util.AgentThreadFactory; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.DoubleSupplier; -import javax.annotation.Nullable; -import okhttp3.Call; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -final class AgentlessConfigurationSource implements ConfigurationSourceService { - private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessConfigurationSource.class); - - private static final String DATADOG_UFC_RULES_BASED_SERVER_PATH = - "/api/v2/feature-flagging/config/rules-based/server"; - private static final int MAX_ATTEMPTS = 3; - private static final int MINUTES_BETWEEN_WARNINGS = 5; - private static final long FIRST_RETRY_MIN_MILLIS = 2_000; - private static final long FIRST_RETRY_MAX_MILLIS = 10_000; - private static final long SECOND_RETRY_MIN_MILLIS = 5_000; - private static final long SECOND_RETRY_MAX_MILLIS = 30_000; - private static final double RETRY_JITTER = 0.2; - - private final HttpUrl endpoint; - private final Config config; - private final long pollIntervalMillis; - private final UfcHttpClient client; - private final ScheduledExecutorService executor; - private final RatelimitedLogger ratelimitedLogger; - private final Object lifecycleLock = new Object(); - private final AtomicBoolean polling = new AtomicBoolean(); - private final AtomicReference pollingThread = new AtomicReference<>(); - private volatile boolean closed; - private volatile boolean started; - private volatile ScheduledFuture scheduledPoll; - private volatile String etag; - - AgentlessConfigurationSource(final Config config) { - this(config, endpoint(config)); - } - - private AgentlessConfigurationSource(final Config config, final HttpUrl endpoint) { - this( - endpoint, - config, - millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), - new OkHttpUfcHttpClient( - OkHttpUtils.buildHttpClient( - endpoint, - millis(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds())), - millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), - TimeUnit.MILLISECONDS::sleep, - () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)), - Executors.newSingleThreadScheduledExecutor( - new AgentThreadFactory(FEATURE_FLAG_CONFIGURATION_POLLER)), - new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); - } - - AgentlessConfigurationSource( - final HttpUrl endpoint, - final Config config, - final long pollIntervalMillis, - final UfcHttpClient client, - final ScheduledExecutorService executor) { - this( - endpoint, - config, - pollIntervalMillis, - client, - executor, - new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); - } - - AgentlessConfigurationSource( - final HttpUrl endpoint, - final Config config, - final long pollIntervalMillis, - final UfcHttpClient client, - final ScheduledExecutorService executor, - final RatelimitedLogger ratelimitedLogger) { - this.endpoint = endpoint; - this.config = config; - this.pollIntervalMillis = pollIntervalMillis; - this.client = client; - this.executor = executor; - this.ratelimitedLogger = ratelimitedLogger; - } - - @Override - public void init() { - synchronized (lifecycleLock) { - if (closed || started) { - return; - } - started = true; - } - - // Complete the first poll cycle on the activation thread. This lets OpenFeature provider - // initialization observe a successful retry before it checks whether configuration is ready. - // No request occurs before application code activates the provider. - pollOnceSafely(); - - synchronized (lifecycleLock) { - if (!closed) { - scheduledPoll = - executor.scheduleWithFixedDelay( - this::pollOnceSafely, - pollIntervalMillis, - pollIntervalMillis, - TimeUnit.MILLISECONDS); - } - } - } - - boolean pollOnce() { - if (closed || !polling.compareAndSet(false, true)) { - return false; - } - pollingThread.set(Thread.currentThread()); - try { - return fetchAndApply(); - } finally { - pollingThread.compareAndSet(Thread.currentThread(), null); - polling.set(false); - } - } - - @Override - public void close() { - final ScheduledFuture poll; - synchronized (lifecycleLock) { - if (closed) { - return; - } - closed = true; - started = false; - poll = scheduledPoll; - scheduledPoll = null; - } - if (poll != null) { - poll.cancel(true); - } - client.cancel(); - final Thread activePollingThread = pollingThread.get(); - if (activePollingThread != null) { - activePollingThread.interrupt(); - } - executor.shutdownNow(); - } - - private void pollOnceSafely() { - try { - pollOnce(); - } catch (final RuntimeException e) { - LOGGER.debug("Unexpected error while polling Feature Flagging HTTP configuration source", e); - } - } - - private boolean fetchAndApply() { - try { - final UfcHttpResponse response = client.fetch(endpoint, config, etag); - if (closed) { - return false; - } - if (isRetryableStatus(response.status)) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", - MAX_ATTEMPTS, - response.status); - return false; - } - synchronized (lifecycleLock) { - return !closed && apply(response); - } - } catch (final IOException e) { - if (!closed) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint request failed after {} attempts", - MAX_ATTEMPTS, - e); - } - return false; - } - } - - private boolean apply(final UfcHttpResponse response) { - if (response.status == HttpURLConnection.HTTP_NOT_MODIFIED) { - return true; - } - if (response.status == HttpURLConnection.HTTP_UNAUTHORIZED - || response.status == HttpURLConnection.HTTP_FORBIDDEN) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - response.status); - return false; - } - if (response.status != HttpURLConnection.HTTP_OK || response.body == null) { - return false; - } - final ServerConfiguration configuration; - try { - configuration = JsonApiUfcResponseParser.INSTANCE.parse(response.body); - } catch (final IOException | RuntimeException e) { - LOGGER.debug("Feature Flagging HTTP configuration source returned malformed UFC payload", e); - return false; - } - if (configuration == null) { - return false; - } - FeatureFlaggingGateway.dispatch(configuration); - updateEtag(response.etag); - return true; - } - - private static boolean isRetryableStatus(final int status) { - return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT - || status == 429 - || (status >= 500 && status <= 599); - } - - private void updateEtag(final String nextEtag) { - etag = isBlank(nextEtag) ? null : nextEtag; - } - - static HttpUrl endpoint(final Config config) { - final String configuredBaseUrl = config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl(); - return configuredBaseUrl == null - ? datadogApiServerDistributionEndpoint(config) - : endpointFromConfiguredBaseUrl(configuredBaseUrl); - } - - private static HttpUrl endpointFromConfiguredBaseUrl(final String configuredBaseUrl) { - final HttpUrl parsed = HttpUrl.parse(configuredBaseUrl.trim()); - if (parsed == null) { - throw new IllegalArgumentException( - "Invalid Feature Flagging HTTP configuration source URL: " + configuredBaseUrl); - } - if ("/".equals(parsed.encodedPath()) || parsed.encodedPath().isEmpty()) { - return parsed - .newBuilder() - .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)) - .build(); - } - return parsed; - } - - private static HttpUrl datadogApiServerDistributionEndpoint(final Config config) { - final HttpUrl.Builder endpoint = - new HttpUrl.Builder() - .scheme("https") - .host("ufc-server.ff-cdn." + config.getSite()) - .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)); - final String env = config.getEnv(); - if (env != null && !env.isEmpty()) { - endpoint.addQueryParameter("dd_env", env); - } - return endpoint.build(); - } - - static long millis(final int seconds) { - return TimeUnit.SECONDS.toMillis(seconds); - } - - static long retryDelayMillis( - final long pollIntervalMillis, final int attempt, final double jitter) { - final long baseDelay; - if (attempt == 1) { - baseDelay = clamp(pollIntervalMillis / 6, FIRST_RETRY_MIN_MILLIS, FIRST_RETRY_MAX_MILLIS); - } else if (attempt == 2) { - baseDelay = clamp(pollIntervalMillis / 3, SECOND_RETRY_MIN_MILLIS, SECOND_RETRY_MAX_MILLIS); - } else { - throw new IllegalArgumentException("Unsupported Feature Flagging retry attempt: " + attempt); - } - return Math.max(1, Math.round(baseDelay * jitter)); - } - - private static long clamp(final long value, final long minimum, final long maximum) { - return Math.max(minimum, Math.min(maximum, value)); - } - - interface UfcHttpClient { - UfcHttpResponse fetch(HttpUrl endpoint, Config config, String etag) throws IOException; - - void cancel(); - } - - interface RetrySleeper { - void sleep(long delayMillis) throws InterruptedException; - } - - static final class UfcHttpResponse { - final int status; - @Nullable final String etag; - @Nullable final byte[] body; - - UfcHttpResponse(final int status, @Nullable final String etag, @Nullable final byte[] body) { - this.status = status; - this.etag = etag; - this.body = body; - } - } - - static final class OkHttpUfcHttpClient implements UfcHttpClient { - private final OkHttpClient httpClient; - private final long pollIntervalMillis; - private final RetrySleeper retrySleeper; - private final DoubleSupplier jitter; - private final AtomicBoolean fetching = new AtomicBoolean(); - private final AtomicBoolean cancelled = new AtomicBoolean(); - private final AtomicReference activeCall = new AtomicReference<>(); - - OkHttpUfcHttpClient(final OkHttpClient httpClient) { - this( - httpClient, - TimeUnit.SECONDS.toMillis(30), - TimeUnit.MILLISECONDS::sleep, - () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); - } - - OkHttpUfcHttpClient( - final OkHttpClient httpClient, - final long pollIntervalMillis, - final RetrySleeper retrySleeper, - final DoubleSupplier jitter) { - this.httpClient = httpClient; - this.pollIntervalMillis = pollIntervalMillis; - this.retrySleeper = retrySleeper; - this.jitter = jitter; - } - - @Override - public UfcHttpResponse fetch(final HttpUrl endpoint, final Config config, final String etag) - throws IOException { - final Map headers = new HashMap<>(); - if (etag != null) { - headers.put("If-None-Match", etag); - } - // Leave Accept-Encoding unset so OkHttp negotiates gzip and transparently decompresses it. - final Request request = - prepareRequest(endpoint, headers, config, isDatadogManagedEndpoint(endpoint, config)) - .get() - .build(); - if (!fetching.compareAndSet(false, true)) { - throw new IllegalStateException("Feature Flagging HTTP request already in flight"); - } - if (cancelled.get()) { - fetching.set(false); - throw new InterruptedIOException("Feature Flagging HTTP client is closed"); - } - try { - final HttpRetryPolicy.Factory retryPolicyFactory = - new HttpRetryPolicy.Factory(0, 0, 0) { - @Override - public HttpRetryPolicy create() { - return new AgentlessRetryPolicy( - cancelled, pollIntervalMillis, retrySleeper, jitter); - } - }; - final Call.Factory callFactory = - retryRequest -> { - final Call call = httpClient.newCall(retryRequest); - activeCall.set(call); - if (cancelled.get()) { - call.cancel(); - } - return call; - }; - return sendWithRetries( - callFactory, - retryPolicyFactory, - request, - response -> { - final int status = response.code(); - final String responseEtag = response.header("ETag"); - try (ResponseBody responseBody = response.body()) { - final byte[] body = responseBody != null ? responseBody.bytes() : null; - return new UfcHttpResponse(status, responseEtag, body); - } - }); - } finally { - activeCall.set(null); - fetching.set(false); - } - } - - @Override - public void cancel() { - cancelled.set(true); - final Call call = activeCall.get(); - if (call != null) { - call.cancel(); - } - } - - private static boolean isDatadogManagedEndpoint(final HttpUrl endpoint, final Config config) { - return config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() == null - && endpoint.isHttps() - && endpoint.host().equalsIgnoreCase("ufc-server.ff-cdn." + config.getSite()); - } - } - - @SuppressFBWarnings( - value = "AT_NONATOMIC_OPERATIONS_ON_SHARED_VARIABLE", - justification = - "Each retry policy belongs to one synchronous HTTP request and is confined to one thread") - static final class AgentlessRetryPolicy extends HttpRetryPolicy { - private final AtomicBoolean cancelled; - private final long pollIntervalMillis; - private final RetrySleeper retrySleeper; - private final DoubleSupplier jitter; - private int retriesLeft = MAX_ATTEMPTS - 1; - private int retryAttempt; - - AgentlessRetryPolicy( - final AtomicBoolean cancelled, - final long pollIntervalMillis, - final RetrySleeper retrySleeper, - final DoubleSupplier jitter) { - super(0, 0, 0, false); - this.cancelled = cancelled; - this.pollIntervalMillis = pollIntervalMillis; - this.retrySleeper = retrySleeper; - this.jitter = jitter; - } - - @Override - public boolean shouldRetry(final Exception exception) { - return exception instanceof IOException - && !Thread.currentThread().isInterrupted() - && reserveRetry(); - } - - @Override - public boolean shouldRetry(@Nullable final Response response) { - return response != null && isRetryableStatus(response.code()) && reserveRetry(); - } - - private boolean reserveRetry() { - if (cancelled.get() || retriesLeft == 0) { - return false; - } - retriesLeft--; - retryAttempt++; - return true; - } - - @Override - public void backoff() throws IOException { - if (cancelled.get()) { - throw new InterruptedIOException("Feature Flagging HTTP client is closed"); - } - try { - retrySleeper.sleep( - retryDelayMillis(pollIntervalMillis, retryAttempt, jitter.getAsDouble())); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new InterruptedIOException("Feature Flagging retry interrupted"); - } - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java deleted file mode 100644 index 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/ExposureCache.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java deleted file mode 100644 index 7125d009a69..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import java.util.Objects; - -public interface ExposureCache { - - boolean add(ExposureEvent event); - - Value get(Key key); - - int size(); - - final class Key { - public final String flag; - public final String subject; - - public Key(final ExposureEvent event) { - this.flag = event.flag == null ? null : event.flag.key; - this.subject = event.subject == null ? null : event.subject.id; - } - - @Override - public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) { - return false; - } - final Key key = (Key) o; - return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); - } - - @Override - public int hashCode() { - return Objects.hash(flag, subject); - } - } - - final class Value { - public final String variant; - public final String allocation; - public final Integer serialId; - - public Value(final ExposureEvent event) { - this.variant = event.variant == null ? null : event.variant.key; - this.allocation = event.allocation == null ? null : event.allocation.key; - this.serialId = event.serial_id; - } - - @Override - public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) { - return false; - } - final Value value = (Value) o; - return Objects.equals(variant, value.variant) - && Objects.equals(allocation, value.allocation) - && Objects.equals(serialId, value.serialId); - } - - @Override - public int hashCode() { - return Objects.hash(variant, allocation, serialId); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java deleted file mode 100644 index 4c9fa80ea0d..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway.ExposureListener; - -/** - * Defines an exposure writer responsible for sending exposure events to the EVP proxy. - * Implementations should use a background thread to perform these operations asynchronously. - */ -public interface ExposureWriter extends AutoCloseable, ExposureListener { - - void init(); - - void close(); -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java deleted file mode 100644 index fcd50e5dc34..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ /dev/null @@ -1,210 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.telemetry.LogCollector.EXCLUDE_TELEMETRY; -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_EXPOSURE_PROCESSOR; -import static datadog.trace.util.AgentThreadFactory.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; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ExposureWriterImpl implements ExposureWriter { - - private static final Logger LOGGER = LoggerFactory.getLogger(ExposureWriterImpl.class); - private static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements - private static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; - private static final int FLUSH_THRESHOLD = 100; - private static final String EXPOSURES_ROUTE = "exposures"; - - private final MessagePassingBlockingQueue 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( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final FeatureFlagBackendApiFactory backendApiFactory, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); - final ExposureSerializingHandler serializer = - new ExposureSerializingHandler( - backendApiFactory, - queue, - flushInterval, - timeUnit, - FeatureFlagEvpContext.from(config), - this::close); - this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); - } - - @Override - public void init() { - FeatureFlaggingGateway.addExposureListener(this); - this.serializerThread.start(); - } - - @Override - public void close() { - FeatureFlaggingGateway.removeExposureListener(this); - if (this.serializerThread.isAlive()) { - this.serializerThread.interrupt(); - } - } - - @Override - 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 long ticksRequiredToFlush; - private long lastTicks; - - private final FeatureFlagEvpPublisher evpPublisher; - private final Map context; - private final ExposureCache cache; - - private final List buffer = new ArrayList<>(); - private final Runnable errorCallback; - - ExposureSerializingHandler( - final FeatureFlagBackendApiFactory backendApiFactory, - final MessagePassingBlockingQueue queue, - final long flushInterval, - final TimeUnit timeUnit, - final Map context, - final Runnable errorCallback) { - this.queue = queue; - this.cache = new LRUExposureCache(queue.capacity()); - this.evpPublisher = - new FeatureFlagEvpPublisher<>(backendApiFactory::create, ExposuresRequest.class); - 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) { - Thread.currentThread().interrupt(); - } - LOGGER.debug("exposure processor worker exited. submitting exposures stopped."); - } - - private void runDutyCycle() throws InterruptedException { - final Thread thread = Thread.currentThread(); - while (!thread.isInterrupted()) { - ExposureEvent event; - while ((event = queue.poll(100, TimeUnit.MILLISECONDS)) != null) { - if (addToBuffer(event)) { - consumeBatch(); - break; - } - } - flushIfNecessary(); - } - } - - private void consumeBatch() { - queue.drain(this::addToBuffer, queue.size()); - } - - /** Adds an element to the buffer taking care of duplicated exposures thanks to the LRU cache */ - private boolean addToBuffer(final ExposureEvent event) { - if (cache.add(event)) { - buffer.add(event); - return true; - } - return false; - } - - protected void flushIfNecessary() { - if (buffer.isEmpty()) { - return; - } - if (shouldFlush()) { - final byte[] payload; - try { - final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); - payload = evpPublisher.serialize(exposures); - } catch (RuntimeException e) { - LOGGER.error(EXCLUDE_TELEMETRY, "Could not serialize exposures; dropping batch", e); - this.buffer.clear(); - return; - } - try { - evpPublisher.post(EXPOSURES_ROUTE, payload); - } catch (Exception e) { - LOGGER.debug("Could not submit exposures", e); - } finally { - // Best-effort delivery must not retry an ambiguously accepted batch. A later definitive - // proxy rejection could otherwise replay the same exposures through direct intake. - this.buffer.clear(); - } - } - } - - private boolean shouldFlush() { - long nanoTime = System.nanoTime(); - long ticks = nanoTime - lastTicks; - if (ticks > ticksRequiredToFlush || queue.size() >= FLUSH_THRESHOLD) { - lastTicks = nanoTime; - return true; - } - return false; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java deleted file mode 100644 index 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/FlagEvaluationAggregator.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java deleted file mode 100644 index 5d0eeaa0e82..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java +++ /dev/null @@ -1,454 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.util.HashingUtils.addToHash; -import static datadog.trace.util.HashingUtils.hash; - -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.TreeMap; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -final class FlagEvaluationAggregator { - - // Design assumptions — document the scale we sized for - static final int EXPECTED_FLAG_COUNT = 2_500; - static final int EXPECTED_FULL_BUCKETS_PER_FLAG = 50; - static final int EXPECTED_USERS_PER_FLAG = 1_000; - static final int PER_FLAG_HEADROOM_MULTIPLIER = 10; - static final int EXPECTED_DEGRADED_BUCKETS_PER_FLAG = 10; - - // Derived sizing — show the math behind the bucket caps below - static final int FULL_BUCKET_SIZING_BASIS = - EXPECTED_FLAG_COUNT * EXPECTED_FULL_BUCKETS_PER_FLAG; // 125_000 - static final int PER_FLAG_BUCKET_SIZING_BASIS = - PER_FLAG_HEADROOM_MULTIPLIER * EXPECTED_USERS_PER_FLAG; // 10_000 - static final int DEGRADED_BUCKET_SIZING_BASIS = - EXPECTED_FLAG_COUNT * EXPECTED_DEGRADED_BUCKETS_PER_FLAG; // 25_000 - - // Enforced bucket caps - static final int GLOBAL_CAP = 131_072; // nearest power of two above FULL_BUCKET_SIZING_BASIS - static final int PER_FLAG_CAP = PER_FLAG_BUCKET_SIZING_BASIS; - static final int DEGRADED_CAP = 32_768; // nearest power of two above DEGRADED_BUCKET_SIZING_BASIS - - private static final byte CTX_TAG_STRING = 's'; - private static final byte CTX_TAG_BOOL = 'b'; - private static final byte CTX_TAG_INT = 'i'; - private static final byte CTX_TAG_LONG = 'l'; - private static final byte CTX_TAG_FLOAT = 'f'; - private static final byte CTX_TAG_DOUBLE = 'd'; - private static final byte CTX_TAG_OTHER = 'o'; - - final Map fullTier = new HashMap<>(); - final Map degradedTier = new HashMap<>(); - final Map perFlagCount = new HashMap<>(); - final AtomicLong droppedDegradedOverflow = new AtomicLong(0); - final AtomicInteger globalFullCount = new AtomicInteger(0); - - void aggregate(final FlagEvalEvent event) { - final boolean isDefault = event.variant == null; - final boolean observeFullEvaluationData = event.observeFullEvaluationData; - // On the protected path the context is dropped on emit, so it must not fragment buckets or be - // stored — otherwise a high-cardinality field (request_id, timestamp) blows out PER_FLAG_CAP - // and spills every subsequent evaluation into the degraded tier. - final Map prunedAttrs = observeFullEvaluationData ? event.attrs : null; - final String ctxKey = observeFullEvaluationData ? canonicalContextKey(prunedAttrs) : ""; - final FullKey fullKey = buildFullKey(event, ctxKey); - - EvalBucket bucket = fullTier.get(fullKey); - if (bucket != null) { - bucket.merge(event.evalTimeMs, isDefault); - bucket.observeFullEvaluationData &= observeFullEvaluationData; - return; - } - - final int flagCount = perFlagCount.getOrDefault(event.flagKey, 0); - if (globalFullCount.get() < GLOBAL_CAP && flagCount < PER_FLAG_CAP) { - fullTier.put( - fullKey, - new EvalBucket( - event.flagKey, - event.variant, - event.allocationKey, - event.targetingKey, - event.errorMessage, - event.evalTimeMs, - isDefault, - prunedAttrs, - observeFullEvaluationData)); - globalFullCount.incrementAndGet(); - perFlagCount.put(event.flagKey, flagCount + 1); - return; - } - - final DegradedKey degradedKey = buildDegradedKey(event); - bucket = degradedTier.get(degradedKey); - if (bucket != null) { - bucket.merge(event.evalTimeMs, isDefault); - bucket.observeFullEvaluationData &= observeFullEvaluationData; - return; - } - - if (degradedTier.size() < DEGRADED_CAP) { - degradedTier.put( - degradedKey, - new EvalBucket( - event.flagKey, - event.variant, - event.allocationKey, - null, - event.errorMessage, - event.evalTimeMs, - isDefault, - null, - observeFullEvaluationData)); - return; - } - - droppedDegradedOverflow.incrementAndGet(); - } - - boolean isEmpty() { - return fullTier.isEmpty() && degradedTier.isEmpty(); - } - - int fullTierSize() { - return fullTier.size(); - } - - long degradedEvaluationCount() { - long count = 0; - for (final EvalBucket bucket : degradedTier.values()) { - count += bucket.count; - } - return count; - } - - int bucketCount() { - return fullTier.size() + degradedTier.size(); - } - - Iterable fullBuckets() { - return fullTier.values(); - } - - Iterable degradedBuckets() { - return degradedTier.values(); - } - - void clear() { - fullTier.clear(); - degradedTier.clear(); - perFlagCount.clear(); - globalFullCount.set(0); - } - - AggregatedState snapshot() { - return new AggregatedState( - new HashMap<>(fullTier), new HashMap<>(degradedTier), droppedDegradedOverflow.get()); - } - - void simulateFullTierAtCap() { - for (int i = globalFullCount.get(); i < GLOBAL_CAP; i++) { - final String key = "synthetic-full-" + i; - fullTier.put( - new FullKey(key, "on", "alloc", false, null, null, "", false), - new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); - globalFullCount.incrementAndGet(); - perFlagCount.merge(key, 1, Integer::sum); - } - } - - void simulateDegradedTierAtCap() { - for (int i = degradedTier.size(); i < DEGRADED_CAP; i++) { - final String key = "synthetic-dg-" + i; - degradedTier.put( - new DegradedKey(key, "on", "alloc", false, null), - new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); - } - } - - void addDegradedBucketForTest( - final String flagKey, - final String variant, - final String allocationKey, - final String errorMessage, - final long evalTimeMs) { - degradedTier.put( - new DegradedKey(flagKey, variant, allocationKey, variant == null, errorMessage), - new EvalBucket( - flagKey, - variant, - allocationKey, - null, - errorMessage, - evalTimeMs, - variant == null, - null, - false)); - } - - private static FullKey buildFullKey(final FlagEvalEvent event, final String ctxKey) { - return new FullKey( - event.flagKey, - event.variant, - event.allocationKey, - event.variant == null, - event.errorMessage, - event.targetingKey, - ctxKey, - event.observeFullEvaluationData); - } - - private static DegradedKey buildDegradedKey(final FlagEvalEvent event) { - return new DegradedKey( - event.flagKey, - event.variant, - event.allocationKey, - event.variant == null, - event.errorMessage); - } - - static String canonicalContextKey(final Map prunedAttrs) { - if (prunedAttrs == null || prunedAttrs.isEmpty()) { - return ""; - } - final Map sorted = - (prunedAttrs instanceof TreeMap) ? prunedAttrs : new TreeMap<>(prunedAttrs); - final StringBuilder sb = new StringBuilder(); - for (final Map.Entry entry : sorted.entrySet()) { - appendLengthDelimited(sb, entry.getKey()); - appendContextValue(sb, entry.getValue()); - } - return sb.toString(); - } - - private static final String HEX_ZEROS = "00000000"; - - private static void appendLengthDelimited(final StringBuilder sb, final String s) { - // 8-char zero-padded hex length prefix, allocation-lean (no String.format on the hot path). - final String hexLength = Integer.toHexString(s.length()); - sb.append(HEX_ZEROS, 0, 8 - hexLength.length()); - sb.append(hexLength); - sb.append(s); - } - - private static void appendContextValue(final StringBuilder sb, final Object v) { - if (v instanceof Boolean) { - sb.append((char) CTX_TAG_BOOL); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Long) { - sb.append((char) CTX_TAG_LONG); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Integer) { - sb.append((char) CTX_TAG_INT); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Float) { - sb.append((char) CTX_TAG_FLOAT); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Double) { - sb.append((char) CTX_TAG_DOUBLE); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof String) { - sb.append((char) CTX_TAG_STRING); - appendLengthDelimited(sb, (String) v); - } else { - sb.append((char) CTX_TAG_OTHER); - appendLengthDelimited(sb, v == null ? "" : v.toString()); - } - } - - static class EvalBucket { - long count; - long firstEvalMs; - long lastEvalMs; - boolean runtimeDefaultUsed; - String flagKey; - String variant; - String allocationKey; - String targetingKey; - String errorMessage; - Map prunedAttrs; - // Consent to emit raw PII. For full-tier buckets this is uniform (consent is a FullKey - // dimension) and the AND-fold on merge is defensive. For degraded-tier buckets consent is NOT - // a key dimension — mixed-consent events merge here — so the AND-fold produces false whenever - // any consent-off event lands in the bucket. That's benign because the degraded wire path - // drops the targeting key and context regardless of consent, so this field has no downstream - // effect for degraded rows. - boolean observeFullEvaluationData; - - EvalBucket( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final boolean runtimeDefaultUsed, - final Map prunedAttrs, - final boolean observeFullEvaluationData) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.targetingKey = targetingKey; - this.errorMessage = errorMessage; - this.firstEvalMs = evalTimeMs; - this.lastEvalMs = evalTimeMs; - this.count = 1; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.prunedAttrs = prunedAttrs; - this.observeFullEvaluationData = observeFullEvaluationData; - } - - int prunedContextFieldCount() { - return prunedAttrs == null ? 0 : prunedAttrs.size(); - } - - void merge(final long evalTimeMs, final boolean isDefault) { - count++; - if (evalTimeMs < firstEvalMs) { - firstEvalMs = evalTimeMs; - } - if (evalTimeMs > lastEvalMs) { - lastEvalMs = evalTimeMs; - } - if (isDefault) { - runtimeDefaultUsed = true; - } - } - } - - static final class FullKey { - private final String flagKey; - private final String variant; - private final String allocationKey; - private final boolean runtimeDefaultUsed; - private final String errorMessage; - private final String targetingKey; - private final String contextKey; - // Part of the key so consent-on and consent-off evaluations never share a bucket. The - // serializer branches on this to hash the targeting key and drop the context, so events with - // different consent produce different wire rows and belong in different buckets. - private final boolean observeFullEvaluationData; - - FullKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final String targetingKey, - final String contextKey, - final boolean observeFullEvaluationData) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.errorMessage = errorMessage; - this.targetingKey = targetingKey; - this.contextKey = contextKey; - this.observeFullEvaluationData = observeFullEvaluationData; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof FullKey)) { - return false; - } - final FullKey fullKey = (FullKey) o; - return runtimeDefaultUsed == fullKey.runtimeDefaultUsed - && observeFullEvaluationData == fullKey.observeFullEvaluationData - && Objects.equals(flagKey, fullKey.flagKey) - && Objects.equals(variant, fullKey.variant) - && Objects.equals(allocationKey, fullKey.allocationKey) - && Objects.equals(errorMessage, fullKey.errorMessage) - && Objects.equals(targetingKey, fullKey.targetingKey) - && Objects.equals(contextKey, fullKey.contextKey); - } - - @Override - public int hashCode() { - // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs - // on this hot bucket-lookup path. - int result = hash(flagKey, variant, allocationKey); - result = addToHash(result, runtimeDefaultUsed); - result = addToHash(result, errorMessage); - result = addToHash(result, targetingKey); - result = addToHash(result, contextKey); - return addToHash(result, observeFullEvaluationData); - } - } - - static final class DegradedKey { - // Unlike FullKey, consent is NOT a bucket dimension here: the wire serializer for degraded rows - // (FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket with isFullTier=false) drops the - // targeting key and context unconditionally, so two degraded buckets differing only in consent - // would emit byte-identical JSON with evaluation_count split — halving effective DEGRADED_CAP - // for zero wire fidelity. Mixed-consent events merge into one bucket; the AND-fold on - // EvalBucket.observeFullEvaluationData still runs but has no downstream effect for degraded - // rows. - private final String flagKey; - private final String variant; - private final String allocationKey; - private final boolean runtimeDefaultUsed; - private final String errorMessage; - - DegradedKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.errorMessage = errorMessage; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof DegradedKey)) { - return false; - } - final DegradedKey that = (DegradedKey) o; - return runtimeDefaultUsed == that.runtimeDefaultUsed - && Objects.equals(flagKey, that.flagKey) - && Objects.equals(variant, that.variant) - && Objects.equals(allocationKey, that.allocationKey) - && Objects.equals(errorMessage, that.errorMessage); - } - - @Override - public int hashCode() { - // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs - // on this hot bucket-lookup path. - int result = hash(flagKey, variant, allocationKey); - result = addToHash(result, runtimeDefaultUsed); - return addToHash(result, errorMessage); - } - } - - static class AggregatedState { - final Map fullTier; - final Map degradedTier; - final long droppedDegradedOverflow; - - AggregatedState( - final Map fullTier, - final Map degradedTier, - final long droppedDegradedOverflow) { - this.fullTier = fullTier; - this.degradedTier = degradedTier; - this.droppedDegradedOverflow = droppedDegradedOverflow; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java deleted file mode 100644 index 3a8734fd516..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java +++ /dev/null @@ -1,294 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import java.io.ByteArrayOutputStream; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -final class FlagEvaluationPayloads { - - private static final byte[] PAYLOAD_SUFFIX = FeatureFlagEvpPublisher.utf8Bytes("]}"); - private static final byte[] JSON_COMMA = FeatureFlagEvpPublisher.utf8Bytes(","); - - /** - * Wire prefix identifying a privacy-preserving, hashed targeting key. Emitted for full-tier rows - * when {@code observeFullEvaluationData} is off. The suffix is the lower-case hex SHA-256 of the - * UTF-8 targeting key (see {@link ULeb128Encoder#hashTargetingKey}). This is a cross-SDK wire - * contract - keep it in sync with the other server SDKs and the UFC/EVP spec. - */ - private static final String HASHED_TARGETING_KEY_PREFIX = "sha256_"; - - private static final JsonAdapter EVENT_JSON_ADAPTER; - private static final JsonAdapter> CONTEXT_JSON_ADAPTER; - - static { - final Moshi moshi = new Moshi.Builder().build(); - EVENT_JSON_ADAPTER = moshi.adapter(FlagEvaluationEvent.class); - final Type contextType = Types.newParameterizedType(Map.class, String.class, String.class); - CONTEXT_JSON_ADAPTER = moshi.adapter(contextType); - } - - private FlagEvaluationPayloads() {} - - static class FlagEvaluationsRequest { - public final Map context; - public final List flagEvaluations; - - FlagEvaluationsRequest( - final Map context, final List flagEvaluations) { - this.context = context; - this.flagEvaluations = flagEvaluations; - } - } - - static EncodedPayloads buildPayloads( - final List events, - final Map context, - final int payloadSizeLimitBytes) { - final byte[] prefix = payloadPrefix(context); - EncodedPayloadBuilder current = new EncodedPayloadBuilder(prefix); - final List payloads = new ArrayList<>(); - long droppedPayloadLimit = 0; - long degradedPayloadLimit = 0; - - for (final FlagEvaluationEvent event : events) { - byte[] eventBytes = encodeEvent(event); - - if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { - payloads.add(current.toByteArray()); - current = new EncodedPayloadBuilder(prefix); - } - - if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { - current.add(eventBytes); - continue; - } - - final FlagEvaluationEvent degraded = event.withoutTargetingKeyAndContext(); - if (degraded != null) { - eventBytes = encodeEvent(degraded); - if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { - payloads.add(current.toByteArray()); - current = new EncodedPayloadBuilder(prefix); - } - if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { - current.add(eventBytes); - degradedPayloadLimit += event.evaluation_count; - continue; - } - } - - droppedPayloadLimit += event.evaluation_count; - } - - if (!current.isEmpty()) { - payloads.add(current.toByteArray()); - } - return new EncodedPayloads(payloads, droppedPayloadLimit, degradedPayloadLimit); - } - - private static byte[] payloadPrefix(final Map context) { - return FeatureFlagEvpPublisher.utf8Bytes( - "{\"context\":" + CONTEXT_JSON_ADAPTER.toJson(context) + ",\"flagEvaluations\":["); - } - - private static byte[] encodeEvent(final FlagEvaluationEvent event) { - return FeatureFlagEvpPublisher.utf8Bytes(EVENT_JSON_ADAPTER.toJson(event)); - } - - static final class EncodedPayloads { - final List bodies; - final long droppedPayloadLimit; - final long degradedPayloadLimit; - - private EncodedPayloads( - final List bodies, - final long droppedPayloadLimit, - final long degradedPayloadLimit) { - this.bodies = bodies; - this.droppedPayloadLimit = droppedPayloadLimit; - this.degradedPayloadLimit = degradedPayloadLimit; - } - } - - private static final class EncodedPayloadBuilder { - private final byte[] prefix; - private final List events = new ArrayList<>(); - private int eventBytes; - - private EncodedPayloadBuilder(final byte[] prefix) { - this.prefix = prefix; - } - - private boolean isEmpty() { - return events.isEmpty(); - } - - private boolean canAdd(final byte[] event, final int payloadSizeLimitBytes) { - return sizeWith(event) <= payloadSizeLimitBytes; - } - - private int sizeWith(final byte[] event) { - return prefix.length + PAYLOAD_SUFFIX.length + eventBytes + event.length + events.size(); - } - - private void add(final byte[] event) { - events.add(event); - eventBytes += event.length; - } - - private byte[] toByteArray() { - final int size = prefix.length + PAYLOAD_SUFFIX.length + eventBytes; - final ByteArrayOutputStream out = new ByteArrayOutputStream(size + events.size()); - out.write(prefix, 0, prefix.length); - for (int i = 0; i < events.size(); i++) { - if (i > 0) { - out.write(JSON_COMMA, 0, JSON_COMMA.length); - } - final byte[] event = events.get(i); - out.write(event, 0, event.length); - } - out.write(PAYLOAD_SUFFIX, 0, PAYLOAD_SUFFIX.length); - return out.toByteArray(); - } - } - - static class FlagEvaluationEvent { - public final long timestamp; - public final FlagKeyObject flag; - public final long first_evaluation; - public final long last_evaluation; - public final long evaluation_count; - public final KeyObject variant; - public final KeyObject allocation; - public final String targeting_key; - public final Boolean runtime_default_used; - public final EventContext context; - public final ErrorObject error; - - FlagEvaluationEvent( - final long timestamp, - final String flagKey, - final long firstEvalMs, - final long lastEvalMs, - final long count, - final String variant, - final String allocation, - final String targetingKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final Map evaluationAttrs) { - this.timestamp = timestamp; - this.flag = new FlagKeyObject(flagKey); - this.first_evaluation = firstEvalMs; - this.last_evaluation = lastEvalMs; - this.evaluation_count = count; - this.variant = (variant != null && !variant.isEmpty()) ? new KeyObject(variant) : null; - this.allocation = - (allocation != null && !allocation.isEmpty()) ? new KeyObject(allocation) : null; - this.targeting_key = targetingKey; - this.runtime_default_used = runtimeDefaultUsed ? Boolean.TRUE : null; - this.context = - (evaluationAttrs != null && !evaluationAttrs.isEmpty()) - ? new EventContext(evaluationAttrs) - : null; - this.error = - (errorMessage != null && !errorMessage.isEmpty()) ? new ErrorObject(errorMessage) : null; - } - - static FlagEvaluationEvent fromBucket( - final FlagEvaluationAggregator.EvalBucket bucket, - final boolean isFullTier, - final boolean observeFullEvaluationData, - final long flushTimeMs) { - final boolean includeRawContext = isFullTier && observeFullEvaluationData; - return new FlagEvaluationEvent( - flushTimeMs, - bucket.flagKey, - bucket.firstEvalMs, - bucket.lastEvalMs, - bucket.count, - bucket.variant, - bucket.allocationKey, - resolveTargetingKey(bucket.targetingKey, isFullTier, observeFullEvaluationData), - bucket.runtimeDefaultUsed, - bucket.errorMessage, - includeRawContext ? bucket.prunedAttrs : null); - } - - private static String resolveTargetingKey( - final String rawTargetingKey, - final boolean isFullTier, - final boolean observeFullEvaluationData) { - if (!isFullTier || rawTargetingKey == null) { - return null; - } - if (observeFullEvaluationData) { - return rawTargetingKey; - } - return HASHED_TARGETING_KEY_PREFIX + ULeb128Encoder.hashTargetingKey(rawTargetingKey); - } - - FlagEvaluationEvent withoutTargetingKeyAndContext() { - if (targeting_key == null && context == null) { - return null; - } - return new FlagEvaluationEvent( - timestamp, - flag.key, - first_evaluation, - last_evaluation, - evaluation_count, - keyOf(variant), - keyOf(allocation), - null, - Boolean.TRUE.equals(runtime_default_used), - messageOf(error), - null); - } - } - - private static String keyOf(final KeyObject object) { - return object == null ? null : object.key; - } - - private static String messageOf(final ErrorObject object) { - return object == null ? null : object.message; - } - - static class KeyObject { - public final String key; - - KeyObject(final String key) { - this.key = key; - } - } - - static class FlagKeyObject { - public final String key; - - FlagKeyObject(final String key) { - this.key = key; - } - } - - static class ErrorObject { - public final String message; - - ErrorObject(final String message) { - this.message = message; - } - } - - static class EventContext { - public final Map evaluation; - - EventContext(final Map evaluation) { - this.evaluation = evaluation; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java deleted file mode 100644 index 2da8ffe0256..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java +++ /dev/null @@ -1,608 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_EVALUATION_PROCESSOR; -import static datadog.trace.util.AgentThreadFactory.newAgentThread; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -import datadog.communication.BackendApi; -import datadog.communication.EvpProxy; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.telemetry.CoreMetricCollector; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Supplier; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * EVP flagevaluation writer for Java. - * - *

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

Two-tier aggregation contract: Full key: (flagKey, variant, allocationKey, runtimeDefault, - * errorMessage, targetingKey, canonical-context-key). Degraded key: (flagKey, variant, - * allocationKey, runtimeDefault, errorMessage) - no targetingKey/context. Canonical context key: - * sorted entries, type-tagged length-delimited encoding - NOT a hash (collision-safe, comparable - * string identity). Context pruning: deterministic (sort before cut), <=256 fields, string values - * <=256 chars; the pruned attributes are what gets aggregated and serialized. Caps: - * globalCap=131072, perFlagCap=10000, degradedCap=32768. Eval-time: min/max of - * firstEvalMs/lastEvalMs across events in the same bucket. Runtime default: absent variant means - * runtimeDefaultUsed=true. Flush interval: 10 seconds. Queue: bounded MessagePassingBlockingQueue - * (capacity 2^16), non-blocking offer; on overflow the event is dropped and the - * droppedQueueOverflow counter is incremented and surfaced on flush. Enqueue: lock-free. Producers - * contend only on the MPSC queue, never on a monitor, so evaluation threads do not serialize - * against each other. Shutdown: close() drains the queue and performs a final flush before the - * worker thread exits. Because enqueue is lock-free, a producer can still offer during shutdown; - * close() sweeps the queue once the worker has been joined, counting any remainder as a closed drop - * so shutdown loss is observable rather than silent. - */ -public class FlagEvaluationWriterImpl implements FlagEvaluationWriter { - - private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationWriterImpl.class); - - static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC - static final int FLUSH_INTERVAL_SECONDS = 10; - - static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = EvpProxy.PAYLOAD_SIZE_LIMIT_BYTES; - static final String FLAG_EVALUATION_DROPPED_METRIC = "flagevaluation.rows.dropped"; - static final String FLAG_EVALUATION_DEGRADED_METRIC = "flagevaluation.rows.degraded"; - static final String FLAG_EVALUATION_SPLITS_METRIC = "flagevaluation.payload.splits"; - static final String FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC = "flagevaluation.context.truncated"; - static final String DROP_REASON_QUEUE_OVERFLOW = "queue_overflow"; - static final String DROP_REASON_CLOSED = "closed"; - static final String DROP_REASON_DEGRADED_CAP = "degraded_cap"; - static final String DROP_REASON_PAYLOAD_LIMIT = "payload_limit"; - static final String DEGRADED_REASON_CARDINALITY_CAP = "cardinality_cap"; - static final String DEGRADED_REASON_PAYLOAD_LIMIT = "payload_limit"; - private static final String FLAG_EVALUATION_ROUTE = "flagevaluation"; - private static final CoreMetricCollector CORE_METRICS = CoreMetricCollector.getInstance(); - - private final MessagePassingBlockingQueue queue; - private final FlagEvaluationSerializingHandler serializer; - private final Thread serializerThread; - private final Object lifecycleLock = new Object(); - private final AtomicBoolean closed = new AtomicBoolean(false); - - private static void countMetric(final String metricName, final long value, final String reason) { - if (value <= 0) { - return; - } - CORE_METRICS.count(metricName, value, reason == null ? null : "reason:" + reason); - } - - /** - * Observable counter for events dropped because the bounded hand-off queue was full when the hook - * tried to enqueue (backpressure). Incremented on the hook thread, surfaced on flush. - */ - private final AtomicLong droppedQueueOverflow = new AtomicLong(0); - - /** - * Per-reason-tag counters for evaluations whose context was truncated by copyPrunedContext. Keyed - * by the sorted comma-separated reason string (e.g. "max_key_length,max_value_length"). - * Incremented on the hook thread, drained and emitted on flush. - */ - private final ConcurrentHashMap contextTruncatedCounts = - new ConcurrentHashMap<>(); - - 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( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final Supplier backendApiSupplier, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); - this.serializer = - new FlagEvaluationSerializingHandler( - backendApiSupplier, - queue, - flushInterval, - timeUnit, - FeatureFlagEvpContext.from(config), - droppedQueueOverflow, - contextTruncatedCounts, - this::close, - FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - this.serializerThread = newAgentThread(FEATURE_FLAG_EVALUATION_PROCESSOR, serializer); - } - - @Override - public void start() { - synchronized (lifecycleLock) { - if (closed.get()) { - return; - } - // Register with the gateway so FlagEvalLoggingHook can route evaluations to this writer - FeatureFlaggingGateway.setFlagEvalWriter(this); - this.serializerThread.start(); - } - } - - /** Test seam: starts the worker thread WITHOUT registering with the global gateway. */ - void startForTest() { - synchronized (lifecycleLock) { - if (closed.get()) { - return; - } - this.serializerThread.start(); - } - } - - /** Test seam: current full-tier bucket count in the worker's aggregator. */ - int aggregatorFullTierSizeForTest() { - return serializer.aggregator.fullTierSize(); - } - - @Override - public void close() { - final boolean workerRunning; - synchronized (lifecycleLock) { - if (!closed.compareAndSet(false, true)) { - return; - } - // Disable and deregister from the gateway so no new events are enqueued. - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - FeatureFlaggingGateway.setFlagEvalWriter(null); - workerRunning = this.serializerThread.isAlive(); - if (workerRunning) { - // Ask the worker to drain the queue and final-flush, then interrupt to wake it from poll(). - serializer.requestShutdown(); - this.serializerThread.interrupt(); - } - } - if (Thread.currentThread() == this.serializerThread) { - return; - } - if (workerRunning) { - try { - // Bounded wait for the worker's final flush so queued events are not lost on shutdown. - this.serializerThread.join(TimeUnit.SECONDS.toMillis(5)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - // enqueue() is lock-free, so a producer that passed the closed check before close() ran can - // still land an event after the worker's final drain. Sweep the remainder so that loss is - // counted rather than silently stranded in a queue nobody polls again. Only safe once the - // worker is gone: the queue is single-consumer, so sweeping alongside a live worker would - // break that contract. If join() timed out the worker is still draining, so skip the sweep. - if (!this.serializerThread.isAlive()) { - sweepAndCountResidualEvents(); - } - } - - /** - * Counts events left in the queue after the worker has exited. Must only be called when the - * serializer thread is not alive - the queue permits a single consumer. - */ - private void sweepAndCountResidualEvents() { - long residual = 0; - while (queue.poll() != null) { - residual++; - } - countMetric(FLAG_EVALUATION_DROPPED_METRIC, residual, DROP_REASON_CLOSED); - } - - @Override - public void enqueue(final FlagEvalEvent event) { - if (event == null) { - return; - } - if (isClosedOrEnqueueDisabled()) { - countClosedDrop(); - return; - } - // Deliberately lock-free: the hand-off queue is MPSC by design, so serializing producers on a - // monitor here would negate that and turn every evaluation in every application thread into - // contention on one lock. A producer that passed the check above can still offer after close() - // has started; that residue is accounted for by the worker's bounded post-drain passes and by - // close()'s post-join sweep, so shutdown loss stays observable. - // - // Safe publication of the event (including the context snapshot built by the hook) comes from - // the queue's own offer/poll ordering, not from any monitor held here. - // - // Non-blocking offer. Count overflow so loss is observable rather than silent; the count is - // surfaced on the next flush. The hook's pre-queue guard (see FlagEvalLoggingHook) samples the - // queue depth before doing any context-copy work, so a saturated queue costs an - // AtomicInteger.get(); the offer here still races with the worker and can legitimately fail. - if (!queue.offer(event)) { - droppedQueueOverflow.incrementAndGet(); - } - } - - /** - * Reports whether the async hand-off queue currently has room for another event. Producers - * consult this before performing any expensive context-copy work so a saturated queue is observed - * as an O(1) read rather than a full snapshot followed by a discarded offer. Best-effort only: - * the worker can drain (or a peer producer can fill) between this check and the subsequent - * enqueue, so callers must still tolerate offer failure. - */ - public boolean hasCapacityForEnqueue() { - return queue.size() < queue.capacity(); - } - - /** Counts one queue-overflow drop without offering an event. */ - public void countPreQueueOverflow() { - droppedQueueOverflow.incrementAndGet(); - } - - @Override - public void countContextTruncated(final String reason) { - contextTruncatedCounts.computeIfAbsent(reason, k -> new AtomicLong(0)).incrementAndGet(); - } - - private boolean isClosedOrEnqueueDisabled() { - return closed.get() || !FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled(); - } - - private void countClosedDrop() { - // Count the drop for any early exit from enqueue, whether closed=true or the gate was disabled - // by the surrounding subsystem. FeatureFlaggingSystem.stop() flips the gate before this - // writer's close() runs, so an in-flight enqueue could race that flip and see gate=false while - // closed=false. Counting on either condition keeps shutdown-loss observable. - countMetric(FLAG_EVALUATION_DROPPED_METRIC, 1, DROP_REASON_CLOSED); - } - - /** Returns the count of events dropped due to queue-overflow backpressure (observable). */ - long droppedQueueOverflow() { - return droppedQueueOverflow.get(); - } - - /** Test seam: returns one queued event without starting the worker. */ - FlagEvalEvent pollQueuedEventForTest() { - return queue.poll(); - } - - /** Test seam: flushes serializer state without starting the worker. */ - void flushForTest() { - serializer.flush(); - } - - // ---- Serializing handler (background thread logic) ---- - - static class FlagEvaluationSerializingHandler implements Runnable { - private final MessagePassingBlockingQueue queue; - private final long ticksRequiredToFlush; - - @SuppressFBWarnings( - value = "AT_NONATOMIC_64BIT_PRIMITIVE", - justification = "the field is confined to the single serializer thread") - private long lastTicks; - - private final FeatureFlagEvpPublisher - evpPublisher; - final Map context; - private final AtomicLong droppedQueueOverflow; - private final ConcurrentHashMap contextTruncatedCounts; - private final Runnable errorCallback; - private final int payloadSizeLimitBytes; - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - // Shutdown coordination: set by close(), drives a final drain+flush before the worker exits. - private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); - private final CountDownLatch finalFlushDone = new CountDownLatch(1); - - FlagEvaluationSerializingHandler( - final Supplier backendApiSupplier, - final MessagePassingBlockingQueue queue, - final long flushInterval, - final TimeUnit timeUnit, - final Map context, - final AtomicLong droppedQueueOverflow, - final ConcurrentHashMap contextTruncatedCounts, - final Runnable errorCallback, - final int payloadSizeLimitBytes) { - this.queue = queue; - this.evpPublisher = - new FeatureFlagEvpPublisher<>( - backendApiSupplier, FlagEvaluationPayloads.FlagEvaluationsRequest.class); - this.context = context; - this.droppedQueueOverflow = droppedQueueOverflow; - this.contextTruncatedCounts = contextTruncatedCounts; - this.payloadSizeLimitBytes = payloadSizeLimitBytes; - this.lastTicks = System.nanoTime(); - this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); - this.errorCallback = errorCallback; - LOGGER.debug("starting flag evaluation serializer"); - } - - /** Signals the worker to drain the queue and perform a final flush before exiting. */ - void requestShutdown() { - shutdownRequested.set(true); - } - - @Override - public void run() { - if (!evpPublisher.start()) { - finalFlushDone.countDown(); - errorCallback.run(); - throw new IllegalArgumentException("EVP Proxy not available"); - } - try { - runDutyCycle(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - // On exit (interrupt or shutdown request), drain everything still buffered and flush it so - // queued events are not lost on shutdown. - // - // 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 - // 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 - // rows already left the queue. - final boolean wasInterrupted = Thread.interrupted(); - try { - drainAndFlush(); - } finally { - finalFlushDone.countDown(); - if (wasInterrupted) { - Thread.currentThread().interrupt(); - } - } - } - LOGGER.debug("flag evaluation processor worker exited."); - } - - private void runDutyCycle() throws InterruptedException { - final Thread thread = Thread.currentThread(); - while (!thread.isInterrupted() && !shutdownRequested.get()) { - final FlagEvalEvent event = queue.poll(100, TimeUnit.MILLISECONDS); - if (event != null) { - aggregateEvent(event); - } - flushIfNecessary(); - } - } - - void drainAndFlush() { - FlagEvalEvent event; - while ((event = queue.poll()) != null) { - aggregateEvent(event); - } - flush(); - } - - // ---- Aggregation logic ---- - - /** Routes an event into the full tier or degraded tier, or drops and counts on overflow. */ - void aggregateEvent(final FlagEvalEvent event) { - try { - aggregator.aggregate(event); - } catch (LinkageError | RuntimeException e) { - LOGGER.debug("Could not aggregate flag evaluation event", e); - } - } - - // ---- Flush logic ---- - - void flushIfNecessary() { - if (shouldFlush()) { - flush(); - } - } - - void flush() { - // Surface backpressure (queue-overflow) drops as an observable warning even when there is - // nothing else to flush. - final long qDrops = droppedQueueOverflow.getAndSet(0); - countMetric(FLAG_EVALUATION_DROPPED_METRIC, qDrops, DROP_REASON_QUEUE_OVERFLOW); - if (qDrops > 0) { - LOGGER.warn( - "flag evaluation queue full - dropped {} evaluation(s) under backpressure" - + " (best-effort telemetry)", - qDrops); - } - final long dgDrops = aggregator.droppedDegradedOverflow.getAndSet(0); - countMetric(FLAG_EVALUATION_DROPPED_METRIC, dgDrops, DROP_REASON_DEGRADED_CAP); - if (dgDrops > 0) { - LOGGER.warn( - "degraded aggregation tier full - dropped {} evaluation(s); raise degraded cap" - + " (best-effort telemetry)", - dgDrops); - } - - // Drain per-reason context-truncation counters and emit one metric per unique reason tag. - for (final Map.Entry entry : contextTruncatedCounts.entrySet()) { - final long count = entry.getValue().getAndSet(0); - if (count > 0) { - countMetric(FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, count, entry.getKey()); - } - } - - if (aggregator.isEmpty()) { - return; - } - try { - countMetric( - FLAG_EVALUATION_DEGRADED_METRIC, - aggregator.degradedEvaluationCount(), - DEGRADED_REASON_CARDINALITY_CAP); - final List events = buildEventList(); - if (events.isEmpty()) { - return; - } - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(events, context, payloadSizeLimitBytes); - countMetric( - FLAG_EVALUATION_DROPPED_METRIC, - payloads.droppedPayloadLimit, - DROP_REASON_PAYLOAD_LIMIT); - countMetric( - FLAG_EVALUATION_DEGRADED_METRIC, - payloads.degradedPayloadLimit, - DEGRADED_REASON_PAYLOAD_LIMIT); - if (payloads.bodies.size() > 1) { - countMetric(FLAG_EVALUATION_SPLITS_METRIC, payloads.bodies.size() - 1, null); - } - if (payloads.droppedPayloadLimit > 0) { - LOGGER.warn( - "flag evaluation payload too large - dropped {} evaluation(s)" - + " (best-effort telemetry)", - payloads.droppedPayloadLimit); - } - for (final byte[] payload : payloads.bodies) { - evpPublisher.post(FLAG_EVALUATION_ROUTE, payload); - } - } catch (Exception e) { - LOGGER.error("Could not submit flag evaluations", e); - } finally { - // Best-effort: always clear the aggregator after a flush attempt. Retaining buckets across - // flushes on encode failure would let one unserializable value (for example a NaN Double a - // customer put in the context) permanently block every subsequent flush. - aggregator.clear(); - lastTicks = System.nanoTime(); - } - } - - private List buildEventList() { - final long flushTimeMs = System.currentTimeMillis(); - // Consent is read per bucket from the value each event snapshotted at evaluation time, not - // from the gateway here: CURRENT_CONFIG may have been overwritten by a later RC update since - // these evaluations happened, and reading it at flush would apply the wrong config's consent. - final List events = - new ArrayList<>(aggregator.bucketCount()); - for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullBuckets()) { - events.add( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, bucket.observeFullEvaluationData, flushTimeMs)); - } - for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedBuckets()) { - events.add( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, false, bucket.observeFullEvaluationData, flushTimeMs)); - } - return events; - } - - private boolean shouldFlush() { - if (aggregator.isEmpty() && droppedQueueOverflow.get() == 0) { - return false; - } - final long nanoTime = System.nanoTime(); - final long ticks = nanoTime - lastTicks; - if (ticks > ticksRequiredToFlush) { - lastTicks = nanoTime; - return true; - } - return false; - } - } - - // ---- Test-seam inner class (package-private) ---- - - /** - * Test-accessible handler that exposes {@link #drainAndAggregate()} and {@link #flush()} without - * starting a real background thread. - */ - static class SerializingHandlerForTest extends FlagEvaluationSerializingHandler { - - SerializingHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - this(backendApiSupplier, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - } - - SerializingHandlerForTest( - final Supplier backendApiSupplier, - final Map context, - final int payloadSizeLimitBytes) { - super( - backendApiSupplier, - Queues.mpscBlockingConsumerArrayQueue(DEFAULT_CAPACITY), - Long.MAX_VALUE, // effectively never auto-flush - TimeUnit.NANOSECONDS, - context, - new AtomicLong(0), - new ConcurrentHashMap<>(), - () -> {}, - payloadSizeLimitBytes); - } - - private final List staged = new ArrayList<>(); - - /** Adds an event to the staged list (simulates hook enqueue). */ - void add(final FlagEvalEvent event) { - staged.add(event); - } - - /** Aggregates all staged events and returns the current aggregation state. */ - FlagEvaluationAggregator.AggregatedState drainAndAggregate() { - for (final FlagEvalEvent e : staged) { - aggregateEvent(e); - } - staged.clear(); - return aggregator.snapshot(); - } - - /** Simulates filling the full tier to GLOBAL_CAP by injecting synthetic distinct buckets. */ - void simulateFullTierAtCap() { - aggregator.simulateFullTierAtCap(); - } - - /** - * Simulates filling the degraded tier to DEGRADED_CAP by injecting synthetic distinct buckets. - */ - void simulateDegradedTierAtCap() { - aggregator.simulateDegradedTierAtCap(); - } - - void addDroppedDegradedOverflowForTest(final long count) { - aggregator.droppedDegradedOverflow.addAndGet(count); - } - - void addDegradedBucketForTest( - final String flagKey, - final String variant, - final String allocationKey, - final String errorMessage, - final long evalTimeMs) { - aggregator.addDegradedBucketForTest( - flagKey, variant, allocationKey, errorMessage, evalTimeMs); - } - - void clearAggregationForTest() { - aggregator.clear(); - } - - int fullTierSizeForTest() { - return aggregator.fullTierSize(); - } - } - - /** Factory method for test use - creates a SerializingHandlerForTest. */ - static SerializingHandlerForTest createHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - return new SerializingHandlerForTest(backendApiSupplier, context); - } - - static SerializingHandlerForTest createHandlerForTest( - final Supplier backendApiSupplier, - final Map context, - final int payloadSizeLimitBytes) { - return new SerializingHandlerForTest(backendApiSupplier, context, payloadSizeLimitBytes); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java deleted file mode 100644 index 0a47d316075..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonReader; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; - -final class JsonApiUfcResponseParser { - - private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; - private static final JsonReader.Options RESPONSE_FIELDS = JsonReader.Options.of("data"); - private static final JsonReader.Options DATA_FIELDS = JsonReader.Options.of("type", "attributes"); - - static final JsonApiUfcResponseParser INSTANCE = - new JsonApiUfcResponseParser(UniversalFlagConfigParser.INSTANCE); - - private final UniversalFlagConfigParser ufcParser; - - JsonApiUfcResponseParser(final UniversalFlagConfigParser ufcParser) { - this.ufcParser = ufcParser; - } - - @Nullable - ServerConfiguration parse(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - if (reader.selectName(RESPONSE_FIELDS) == 0) { - configuration = parseData(reader); - } else { - reader.skipName(); - reader.skipValue(); - } - } - reader.endObject(); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - private ServerConfiguration parseData(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - String type = null; - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - switch (reader.selectName(DATA_FIELDS)) { - case 0: - if (reader.peek() == JsonReader.Token.STRING) { - type = reader.nextString(); - } else { - reader.skipValue(); - } - break; - case 1: - configuration = ufcParser.parse(reader); - break; - default: - reader.skipName(); - reader.skipValue(); - } - } - reader.endObject(); - return UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(type) - ? validConfiguration(configuration) - : null; - } - - @Nullable - private static ServerConfiguration validConfiguration( - @Nullable final ServerConfiguration configuration) { - return configuration != null && configuration.flags != null ? configuration : null; - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java deleted file mode 100644 index e21752cc529..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.core.util.LRUCache; -import java.util.Map; - -/** - * This class is intentionally not thread-safe. Thread safety is ensured by the single-threaded - * access pattern managed by {@link ExposureWriterImpl.ExposureSerializingHandler}. - */ -public class LRUExposureCache implements ExposureCache { - - private final Map cache; - - public LRUExposureCache(final int capacity) { - cache = new LRUCache<>(capacity); - } - - @Override - public boolean add(final ExposureEvent event) { - final Key key = new Key(event); - final Value value = new Value(event); - final Value oldValue = cache.put(key, value); - return oldValue == null || !oldValue.equals(value); - } - - @Override - public Value get(final Key key) { - return cache.get(key); - } - - @Override - public int size() { - return cache.size(); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index e66a6231990..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 27fd3f70c70..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ /dev/null @@ -1,641 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.remoteconfig.ConfigurationDeserializer; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Feature; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; -import datadog.trace.api.featureflag.ufc.v1.Rule; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -final class UniversalFlagConfigParser implements ConfigurationDeserializer { - - private static final Logger LOGGER = LoggerFactory.getLogger(UniversalFlagConfigParser.class); - - static final String INVALID_FLAG = "invalid_flag"; - static final String INVALID_SEMVER_COMPARAND = "invalid_semver_comparand"; - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; - - /** - * Side-channel for tracking flags that failed semver comparand validation during parsing. Cleared - * and populated by {@link #parse(JsonReader)} so that {@link ServerConfiguration#invalidFlags} - * can be set after the Moshi adapter returns. - */ - static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - - static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); - - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Instant.class, new InstantAdapter()) - .add(ShardAdapter.FACTORY) - .add(AllocationAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .add(LenientBooleanAdapter.FACTORY) - .add(FeatureListAdapter.FACTORY) - .build(); - private static final JsonAdapter V1_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - - private UniversalFlagConfigParser() {} - - @Override - public ServerConfiguration deserialize(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - final ServerConfiguration configuration = parse(reader); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - ServerConfiguration parse(final JsonReader reader) throws IOException { - INVALID_FLAGS_HOLDER.get().clear(); - final ServerConfiguration configuration = V1_ADAPTER.fromJson(reader); - if (configuration != null) { - final Map invalid = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalid.isEmpty()) { - configuration.invalidFlags = invalid; - } - } - INVALID_FLAGS_HOLDER.get().clear(); - return configuration; - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } - - /** Converts a UFC uint32 wire value to its binary-compatible int representation. */ - private static int toUnsignedInt(@Nullable final Long value, final String fieldName) { - if (value == null) { - return 0; - } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new InvalidFlagException("flag contains an invalid " + fieldName + " value"); - } - return value.intValue(); - } - - /** Validates the required nested UFC fields and SemVer comparands for a flag. */ - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; - } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; - } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" contains a split with missing shards"); - } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new InvalidFlagException("flag \"" + flagKey + "\" contains invalid shards"); - } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } - } - } - } - } - validateAndCacheSemverComparands(flagKey, flag); - } - - private static void validateConditionOperands(final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; - } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" has a non-numeric condition"); - } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new InvalidFlagException("flag \"" + flagKey + "\" has a non-list condition"); - } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" has a non-boolean condition"); - } - break; - default: - break; - } - } - } - } - - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. Throws {@link - * InvalidSemverComparandException} if any condition has an invalid or non-string comparand value. - */ - private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { - final Allocation allocation = flag.allocations.get(allocIdx); - if (allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; - } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - throw new InvalidSemverComparandException( - "flag \"" - + flagKey - + "\" allocation " - + allocIdx - + " rule has condition with operator \"" - + condition.operator - + "\" that requires string value"); - } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - throw new InvalidSemverComparandException( - "flag \"" - + flagKey - + "\" allocation " - + allocIdx - + " rule has condition with operator \"" - + condition.operator - + "\" and invalid semantic version \"" - + condition.value - + "\""); - } - condition.semverComparand = parsed; - break; - default: - // Non-semver operators are not validated here - break; - } - } - } - } - } - - /** Thrown when a flag has an invalid UFC shape. */ - static final class InvalidFlagException extends IllegalArgumentException { - InvalidFlagException(final String message) { - super(message); - } - } - - /** Thrown when a SEMVER_* condition has an invalid or non-string comparand value. */ - static final class InvalidSemverComparandException extends IllegalArgumentException { - InvalidSemverComparandException(final String message) { - super(message); - } - } - - /** - * Reads UFC uint32 shard fields through Long first, then preserves the bootstrap model's int ABI - * by storing the accepted value as its two's-complement bit pattern. - */ - static final class ShardAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || type != Shard.class) { - return null; - } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - } - }; - - private final JsonAdapter delegate; - - ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Nullable - @Override - public Shard fromJson(@Nonnull final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); - } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } - - static final class ShardRangeJson { - Long start; - Long end; - } - - static final class FlagMapAdapter extends JsonAdapter> { - - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - } - }; - - private final JsonAdapter flagAdapter; - - FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } - - @Nullable - @Override - public Map fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException error) { - INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_FLAG); - if (error instanceof InvalidSemverComparandException) { - INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_SEMVER_COMPARAND); - } - LOGGER.warn( - "Dropping malformed FFE flag {} during remote config deserialization: {}", - flagKey, - error.toString()); - } - } - reader.endObject(); - return flags; - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - /** - * Reads a boxed Boolean tolerantly: JSON null and wrong-typed values (string, number, object, - * array) map to null rather than aborting the enclosing object's parse. Downstream reads must use - * Boolean.TRUE.equals(field) so null resolves to the privacy-preserving default. - * - *

Only applies to Boolean.class (not primitive boolean), so mandatory primitive-boolean fields - * (e.g. Flag.enabled) keep their strict parse. - */ - static final class LenientBooleanAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || type != Boolean.class) { - return null; - } - return new LenientBooleanAdapter(); - } - }; - - @Nullable - @Override - public Boolean fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.BOOLEAN) { - return reader.nextBoolean(); - } - // null and every wrong-typed value collapse to null so the caller falls back to its default - // rather than the enclosing config being rejected wholesale. - reader.skipValue(); - return null; - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Boolean value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - /** - * Reads a split's features. Features only feed exposure hooks, so a malformed list or entry is - * dropped instead of rejecting the flag: it must never change which variation a subject gets. - */ - static final class FeatureListAdapter extends JsonAdapter> { - - private static final Type FEATURES_TYPE = Types.newParameterizedType(List.class, Feature.class); - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, FEATURES_TYPE)) { - return null; - } - return new FeatureListAdapter(); - } - }; - - @Nullable - @Override - public List fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { - reader.skipValue(); - return null; - } - final List features = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - final Feature feature = readFeature(reader); - if (feature != null) { - features.add(feature); - } - } - reader.endArray(); - return Collections.unmodifiableList(features); - } - - @Nullable - private static Feature readFeature(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - String key = null; - Object value = null; - List destinations = Collections.emptyList(); - reader.beginObject(); - while (reader.hasNext()) { - final String name = reader.nextName(); - if ("key".equals(name) && reader.peek() == JsonReader.Token.STRING) { - key = reader.nextString(); - } else if ("value".equals(name)) { - value = readScalar(reader); - } else if ("destinations".equals(name)) { - destinations = readDestinations(reader); - } else { - reader.skipValue(); - } - } - reader.endObject(); - if (key == null || key.isEmpty() || value == null || destinations.isEmpty()) { - return null; - } - return new Feature(key, value, destinations); - } - - /** Keeps every non-empty string, including destinations this SDK does not know yet. */ - private static List readDestinations(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { - reader.skipValue(); - return Collections.emptyList(); - } - final List destinations = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - if (reader.peek() == JsonReader.Token.STRING) { - final String destination = reader.nextString(); - if (!destination.isEmpty()) { - destinations.add(destination); - } - } else { - reader.skipValue(); - } - } - reader.endArray(); - return Collections.unmodifiableList(destinations); - } - - @Nullable - private static Object readScalar(final JsonReader reader) throws IOException { - switch (reader.peek()) { - case STRING: - return reader.nextString(); - case NUMBER: - return reader.nextDouble(); - case BOOLEAN: - return reader.nextBoolean(); - default: - reader.skipValue(); - return null; - } - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final List value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class InstantAdapter extends JsonAdapter { - - @Nullable - @Override - public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - return parseInstant(reader.nextString()); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Instant value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class AllocationAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, Allocation.class)) { - return null; - } - return new AllocationAdapter(moshi.adapter(AllocationJson.class)); - } - }; - - private final JsonAdapter delegate; - - AllocationAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Nullable - @Override - public Allocation fromJson(@Nonnull final JsonReader reader) throws IOException { - final AllocationJson allocation = delegate.fromJson(reader); - if (allocation == null) { - return null; - } - return Allocation.fromInstants( - allocation.key, - allocation.rules, - allocation.startAt, - allocation.endAt, - allocation.splits, - allocation.doLog); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Allocation value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class AllocationJson { - String key; - List rules; - Instant startAt; - Instant endAt; - List splits; - Boolean doLog; - } - - @Nullable - private static Instant parseInstant(final String date) { - try { - return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); - } catch (Exception e) { - // ignore wrongly set dates - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java deleted file mode 100644 index b02bd05642b..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java +++ /dev/null @@ -1,1363 +0,0 @@ -package com.datadog.featureflag; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import datadog.communication.http.OkHttpUtils; -import datadog.logging.RatelimitedLogger; -import datadog.trace.agent.test.server.http.JavaTestHttpServer; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.zip.GZIPOutputStream; -import okhttp3.Call; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Protocol; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class AgentlessConfigurationSourceTest { - private static final String CONFIG_PATH = "/api/v2/feature-flagging/config/rules-based/server"; - - @Mock private FeatureFlaggingGateway.ConfigListener listener; - - @AfterEach - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - @Test - void derivesDatadogUfcCdnEndpointFromSiteAndEnv() { - final Config config = config("datad0g.com", "staging env"); - - assertEquals( - "https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging%20env", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void derivesDatadogUfcCdnEndpointWithoutEnv() { - assertEquals( - "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config("datadoghq.com", "")).toString()); - assertEquals( - "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config("datadoghq.com", null)).toString()); - } - - @Test - void appendsRulesBasedServerPathToConfiguredAgentlessBaseUrl() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://mock-backend:8080"); - - assertEquals( - "http://mock-backend:8080/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void usesConfiguredAgentlessEndpointWithPathUnchanged() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://mock-backend:8080/custom/ufc?tenant=test"); - - assertEquals( - "http://mock-backend:8080/custom/ufc?tenant=test", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void rejectsInvalidConfiguredAgentlessBaseUrl() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("not a URL"); - - assertThrows( - IllegalArgumentException.class, () -> AgentlessConfigurationSource.endpoint(config)); - } - - @Test - void rejectsInvalidDatadogUfcCdnEndpoint() { - assertThrows( - IllegalArgumentException.class, - () -> AgentlessConfigurationSource.endpoint(config("datadoghq.com:bad", ""))); - } - - @Test - void defaultConstructorBuildsHttpClientFromConfig() { - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource(config("datad0g.com", "staging")); - - service.close(); - } - - @Test - void realHttpClientDoesNotSendApiKeyOverHttp() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> - api.getResponse() - .addHeader("ETag", "etag-b") - .send(emptyConfig()))))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), "etag-a"); - - assertEquals(HttpURLConnection.HTTP_OK, response.status); - assertEquals("etag-b", response.etag); - assertEquals(emptyConfig(), new String(response.body, UTF_8)); - assertNull(server.getLastRequest().getHeader("DD-API-KEY")); - assertEquals("etag-a", server.getLastRequest().getHeader("If-None-Match")); - assertEquals("java", server.getLastRequest().getHeader("Datadog-Meta-Lang")); - assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void sendsApiKeyToDefaultDatadogHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final HttpUrl endpoint = HttpUrl.get("https://ufc-server.ff-cdn.datadoghq.com" + CONFIG_PATH); - - client.fetch(endpoint, config(), null); - - assertEquals("test-api-key", requests.get(0).header("DD-API-KEY")); - } - - @Test - void stripsApiKeyFromCustomHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("https://flags.example.test/custom/ufc"); - final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); - - client.fetch(endpoint, config, null); - - assertNull(requests.get(0).header("DD-API-KEY")); - } - - @Test - void stripsApiKeyFromUnexpectedHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); - - client.fetch(endpoint, config(), null); - - assertNull(requests.get(0).header("DD-API-KEY")); - } - - @Test - void handlesGzipAndKeepsLastKnownGoodWhenNextResponseIsTruncated() throws Exception { - final byte[] compressedConfig = gzip(emptyConfig()); - final byte[] truncatedConfig = Arrays.copyOf(compressedConfig, compressedConfig.length - 8); - final AtomicInteger responses = new AtomicInteger(); - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - final boolean firstResponse = responses.getAndIncrement() == 0; - final byte[] body = - firstResponse ? compressedConfig : truncatedConfig; - api.getResponse() - .addHeader("Content-Encoding", "gzip") - .addHeader("ETag", firstResponse ? "etag-good" : "etag-bad") - .sendWithType("application/json", body); - })))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - endpoint, - config(), - 30_000, - new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, delay -> {}, () -> 1.0), - Executors.newSingleThreadScheduledExecutor()); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("Staging", configuration.getValue().environment.name); - assertEquals(4, responses.get()); - assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); - assertEquals("etag-good", server.getLastRequest().getHeader("If-None-Match")); - } finally { - service.close(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void downloadsAndAppliesLargeUfcWithoutPayloadLimit() throws Exception { - final int flagCount = 5_000; - final String largeConfig = largeConfig(flagCount); - assertTrue(largeConfig.getBytes(UTF_8).length > 500_000); - - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> s.handlers(h -> h.get(CONFIG_PATH, api -> api.getResponse().send(largeConfig))))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - endpoint, - config(), - 30_000, - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient), - Executors.newSingleThreadScheduledExecutor()); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertTrue(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals(flagCount, configuration.getValue().flags.size()); - } finally { - service.close(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientAllowsMissingEtagAndEmptyResponseBody() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> - api.getResponse() - .status(HttpURLConnection.HTTP_NO_CONTENT) - .send())))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null); - - assertEquals(HttpURLConnection.HTTP_NO_CONTENT, response.status); - assertNull(response.etag); - assertEquals(0, response.body.length); - assertNull(server.getLastRequest().getHeader("If-None-Match")); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void httpClientAdapterPreservesMissingResponseBody() throws Exception { - final OkHttpClient httpClient = mock(OkHttpClient.class); - final Call call = mock(Call.class); - final HttpUrl endpoint = HttpUrl.get("http://localhost"); - final okhttp3.Request request = new okhttp3.Request.Builder().url(endpoint).build(); - final Response okHttpResponse = - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(HttpURLConnection.HTTP_OK) - .message("OK") - .build(); - when(httpClient.newCall(any())).thenReturn(call); - when(call.execute()).thenReturn(okHttpResponse); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(endpoint, config(), null); - - assertEquals(HttpURLConnection.HTTP_OK, response.status); - assertNull(response.etag); - assertNull(response.body); - } - - @Test - void realHttpClientCancellationInterruptsInFlightRequest() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - requestStarted.countDown(); - assertTrue(releaseRequest.await(1, TimeUnit.SECONDS)); - api.getResponse().send(emptyConfig()); - })))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future response = - runner.submit( - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - assertThrows( - IllegalStateException.class, - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - - client.cancel(); - - final ExecutionException failure = - assertThrows(ExecutionException.class, () -> response.get(1, TimeUnit.SECONDS)); - assertInstanceOf(IOException.class, failure.getCause()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientCancellationBeforeFetchPreventsRequest() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> h.get(CONFIG_PATH, api -> api.getResponse().send(emptyConfig()))))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - client.cancel(); - - assertThrows( - IOException.class, - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientTimesOutDelayedResponse() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - TimeUnit.MILLISECONDS.sleep(500); - api.getResponse().send(emptyConfig()); - })))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = OkHttpUtils.buildHttpClient(endpoint, 50); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, delay -> {}, () -> 1.0); - - try { - assertThrows(IOException.class, () -> client.fetch(endpoint, config(), null)); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void appliesAcceptedJsonApiUfcThroughGateway() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("2026-07-15T19:57:07.219869778Z", configuration.getValue().createdAt); - assertNull(configuration.getValue().format); - assertEquals("Staging", configuration.getValue().environment.name); - assertTrue(configuration.getValue().flags.isEmpty()); - assertNull(client.requests.get(0).etag); - } - - @Test - void customEndpointRequiresJsonApiAndKeepsLastKnownGoodOnRawUfc() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-good", emptyConfig()), - response(200, "etag-raw", emptyConfigAttributes())); - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://custom-backend/custom/ufc"); - final AgentlessConfigurationSource service = service(client, config); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("Staging", configuration.getValue().environment.name); - assertNull(client.requests.get(0).etag); - assertEquals("etag-good", client.requests.get(1).etag); - } - - @Test - void ignoresBlankEtag() throws Exception { - final FakeClient client = - new FakeClient(response(200, " ", emptyConfig()), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void successfulResponseWithoutEtagClearsPreviousEtag() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), - response(200, null, emptyConfig()), - response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertEquals("etag-a", client.requests.get(1).etag); - assertNull(client.requests.get(2).etag); - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - } - - @Test - void usesEtagAndSkipsDispatchOnUnchangedConfig() throws Exception { - final FakeClient client = - new FakeClient(response(200, "etag-a", emptyConfig()), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - verify(listener).accept(any(ServerConfiguration.class)); - assertEquals("etag-a", client.requests.get(1).etag); - } - - @Test - void coldNotModifiedDoesNotEstablishEtag() throws Exception { - final FakeClient client = - new FakeClient(response(304, "etag-cold", null), response(200, "etag-warm", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void failedGatewayDispatchDoesNotAdvanceEtag() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - final FeatureFlaggingGateway.ConfigListener failingListener = - configuration -> { - throw new IllegalStateException("listener rejected configuration"); - }; - FeatureFlaggingGateway.addConfigListener(failingListener); - - try { - assertThrows(IllegalStateException.class, service::pollOnce); - FeatureFlaggingGateway.removeConfigListener(failingListener); - - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - } finally { - FeatureFlaggingGateway.removeConfigListener(failingListener); - } - } - - @Test - void keepsLastKnownGoodOnAuthFailureAndMalformedPayload() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-good", emptyConfig()), - response(401, null, null), - response(200, null, "{not-json}"), - response(200, null, "{\"flags\":[]}")); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(any(ServerConfiguration.class)); - assertEquals("etag-good", client.requests.get(1).etag); - assertEquals("etag-good", client.requests.get(2).etag); - assertEquals("etag-good", client.requests.get(3).etag); - } - - @Test - void rejectsForbiddenNonOkMissingBodyAndNullConfiguration() throws Exception { - final FakeClient client = - new FakeClient( - response(403, null, null), - response(404, null, null), - response(600, null, null), - response(200, null, null), - response(200, null, "null"), - response(200, null, jsonApiResponse("other-configuration", emptyConfigAttributes())), - response(200, null, "{\"data\":null}"), - response( - 200, null, "{\"data\":{\"id\":\"1\",\"type\":\"universal-flag-configuration\"}}"), - response(200, null, emptyConfigAttributes())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verifyNoInteractions(listener); - } - - @Test - void warnsRateLimitedOnUnauthorizedAndForbidden() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final FakeClient client = new FakeClient(response(401, null, null), response(403, null, null)); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - - try { - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - HttpURLConnection.HTTP_UNAUTHORIZED); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - HttpURLConnection.HTTP_FORBIDDEN); - } finally { - service.close(); - } - } - - @Test - void retriesTimeoutBeforeApplyingConfig() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void retriesClientTimeoutAndRateLimitStatusBeforeApplyingConfig() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(408, null, null), - response(200, "etag-a", emptyConfig()), - response(429, null, null), - response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertEquals(4, requests.size()); - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - } - - @Test - void retriesServerErrorThenKeepsColdStateOnNotModified() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, delay -> {}, () -> 1.0, response(500, null, null), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(2, requests.size()); - verifyNoInteractions(listener); - } - - @Test - void warnsRateLimitedAfterRetryableFailuresAreExhausted() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(503, null, null), - response(503, null, null), - response(503, null, null)); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertFalse(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", 3, 503); - verifyNoInteractions(listener); - } finally { - service.close(); - } - } - - @Test - void warnsRateLimitedAfterIoFailuresAreExhausted() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final SocketTimeoutException finalFailure = - new SocketTimeoutException("slow HTTP configuration source"); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - new SocketTimeoutException("slow HTTP configuration source"), - finalFailure); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertFalse(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint request failed after {} attempts", - 3, - finalFailure); - verifyNoInteractions(listener); - } finally { - service.close(); - } - } - - @Test - void usesIntervalAwareRetryBackoff() throws Exception { - final List delays = new ArrayList<>(); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delays::add, - () -> 1.0, - response(503, null, null), - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(Arrays.asList(5_000L, 10_000L), delays); - assertEquals(3, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void retryPolicyRejectsNonIoAndInterruptedIoFailures() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean()); - - assertFalse(policy.shouldRetry(new IllegalStateException("not an I/O failure"))); - - Thread.currentThread().interrupt(); - try { - assertFalse(policy.shouldRetry(new IOException("interrupted"))); - } finally { - assertTrue(Thread.interrupted()); - } - } - - @Test - void retryPolicyRejectsMissingResponse() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean()); - - assertFalse(policy.shouldRetry((Response) null)); - } - - @Test - void cancelledRetryPolicyRejectsBackoff() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean(true)); - - assertThrows(InterruptedIOException.class, policy::backoff); - } - - @Test - void clampsAndJittersRetryBackoff() { - assertEquals(2_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 1, 1.0)); - assertEquals(5_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 2, 1.0)); - assertEquals(10_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 1, 1.0)); - assertEquals(30_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 2, 1.0)); - assertEquals(6_000, AgentlessConfigurationSource.retryDelayMillis(30_000, 1, 1.2)); - assertThrows( - IllegalArgumentException.class, - () -> AgentlessConfigurationSource.retryDelayMillis(30_000, 3, 1.0)); - } - - @Test - void rejectsOverlappingPolls() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newFixedThreadPool(2); - - try { - final Future first = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - final Future second = runner.submit(service::pollOnce); - - assertFalse(second.get(1, TimeUnit.SECONDS)); - releaseRequest.countDown(); - assertTrue(first.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.calls.get()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void initCompletesFirstPollAndCloseCancelsScheduledFuture() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 60_000, - client, - Executors.newSingleThreadScheduledExecutor()); - FeatureFlaggingGateway.addConfigListener(listener); - - service.init(); - assertEquals(1, client.calls.get()); - service.close(); - - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void initCompletesInitialRetryCycleBeforeReturning() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(500, null, null), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 60_000, - client, - Executors.newSingleThreadScheduledExecutor()); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - service.init(); - - assertEquals(2, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } finally { - service.close(); - } - } - - @Test - void repeatedInitStartsOnlyOnePoller() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.init(); - service.init(); - awaitCalls(client, 1); - service.close(); - - assertEquals(1, client.calls.get()); - } - - @Test - void scheduledPollContinuesAfterListenerRuntimeException() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 10, - client, - Executors.newSingleThreadScheduledExecutor()); - final AtomicInteger listenerCalls = new AtomicInteger(); - // Wait on the listener rather than on FakeClient.calls: the call counter is incremented when - // a request starts, so it reaches 2 before the second configuration has been applied. - final CountDownLatch listenerNotified = new CountDownLatch(2); - final FeatureFlaggingGateway.ConfigListener flakyListener = - configuration -> { - final int call = listenerCalls.incrementAndGet(); - listenerNotified.countDown(); - if (call == 1) { - throw new IllegalStateException("listener rejected first configuration"); - } - }; - FeatureFlaggingGateway.addConfigListener(flakyListener); - - try { - service.init(); - assertTrue(listenerNotified.await(5, TimeUnit.SECONDS)); - - assertEquals(2, listenerCalls.get()); - assertNull(client.requests.get(1).etag); - } finally { - service.close(); - FeatureFlaggingGateway.removeConfigListener(flakyListener); - } - } - - @Test - void closeCancelsInFlightRequestAndIgnoresLateSuccess() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - final Future poll = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - assertFalse(poll.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.cancelCalls.get()); - assertEquals(1, client.calls.get()); - verifyNoInteractions(listener); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void closeDuringIoFailurePreventsRetry() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = - new FakeClient(new SocketTimeoutException("slow HTTP configuration source")); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future poll = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - assertFalse(poll.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.calls.get()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void closeInterruptsRetryBackoff() throws Exception { - final CountDownLatch backoffStarted = new CountDownLatch(1); - final List requests = new ArrayList<>(); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> { - backoffStarted.countDown(); - TimeUnit.MINUTES.sleep(1); - }, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), config(), 30_000, client, executor); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future initialization = runner.submit(service::init); - assertTrue(backoffStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - initialization.get(1, TimeUnit.SECONDS); - assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS)); - assertEquals(1, requests.size()); - } finally { - runner.shutdownNow(); - } - } - - @Test - void closePreventsFurtherPolls() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.close(); - - assertFalse(service.pollOnce()); - assertEquals(0, client.calls.get()); - } - - @Test - void initAfterCloseDoesNotSchedulePoll() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.close(); - service.init(); - - assertEquals(0, client.calls.get()); - } - - @Nested - class SystemTestParity { - @Test - void preservesSystemTestSourceTransitionsAndLastKnownGoodState() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(200, "etag-a", emptyConfig()), - response(304, "etag-must-not-replace-a", null), - response(509, null, null), - response(200, "etag-b", emptyConfig()), - response(200, "etag-c", "{not-json}"), - response(401, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - assertEquals("etag-a", requests.get(1).header("If-None-Match")); - assertEquals("etag-a", requests.get(2).header("If-None-Match")); - assertEquals("etag-a", requests.get(3).header("If-None-Match")); - assertEquals("etag-b", requests.get(4).header("If-None-Match")); - assertEquals("etag-b", requests.get(5).header("If-None-Match")); - } - } - - private static AgentlessConfigurationSource service( - final AgentlessConfigurationSource.UfcHttpClient client) { - return new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - Executors.newSingleThreadScheduledExecutor()); - } - - private static AgentlessConfigurationSource service( - final FakeClient client, final Config config) { - return new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config, - 30_000, - client, - Executors.newSingleThreadScheduledExecutor()); - } - - private static AgentlessConfigurationSource.OkHttpUfcHttpClient scriptedClient( - final List requests, - final AgentlessConfigurationSource.RetrySleeper retrySleeper, - final java.util.function.DoubleSupplier jitter, - final Object... outcomes) - throws IOException { - final BlockingQueue scriptedOutcomes = new LinkedBlockingQueue<>(); - scriptedOutcomes.addAll(Arrays.asList(outcomes)); - final OkHttpClient httpClient = mock(OkHttpClient.class); - when(httpClient.newCall(any())) - .thenAnswer( - invocation -> { - final okhttp3.Request request = invocation.getArgument(0); - requests.add(request); - final Object outcome = scriptedOutcomes.remove(); - final Call call = mock(Call.class); - when(call.execute()) - .thenAnswer( - ignored -> { - if (outcome instanceof IOException) { - throw (IOException) outcome; - } - return okHttpResponse( - request, (AgentlessConfigurationSource.UfcHttpResponse) outcome); - }); - return call; - }); - return new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, retrySleeper, jitter); - } - - private static AgentlessConfigurationSource.AgentlessRetryPolicy retryPolicy( - final AtomicBoolean cancelled) { - return new AgentlessConfigurationSource.AgentlessRetryPolicy( - cancelled, 30_000, delay -> {}, () -> 1.0); - } - - private static Response okHttpResponse( - final okhttp3.Request request, final AgentlessConfigurationSource.UfcHttpResponse response) { - final Response.Builder builder = - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(response.status) - .message(Integer.toString(response.status)); - if (response.etag != null) { - builder.header("ETag", response.etag); - } - if (response.body != null) { - builder.body(ResponseBody.create(MediaType.get("application/json"), response.body)); - } - return builder.build(); - } - - private static Config config() { - return config("datadoghq.com", ""); - } - - private static Config config(final String site, final String env) { - final Config config = mock(Config.class); - lenient() - .when(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()) - .thenReturn(30); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()) - .thenReturn(5); - lenient().when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()).thenReturn(null); - lenient().when(config.getApiKey()).thenReturn("test-api-key"); - lenient().when(config.getSite()).thenReturn(site); - lenient().when(config.getEnv()).thenReturn(env); - return config; - } - - private static AgentlessConfigurationSource.UfcHttpResponse response( - final int status, final String etag, final String body) { - return new AgentlessConfigurationSource.UfcHttpResponse( - status, etag, body == null ? null : body.getBytes(UTF_8)); - } - - private static String emptyConfig() { - return jsonApiResponse("universal-flag-configuration", emptyConfigAttributes()); - } - - private static String emptyConfigAttributes() { - return "{" - + "\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," - + "\"environment\":{\"name\":\"Staging\"}," - + "\"flags\":{}" - + "}"; - } - - private static String jsonApiResponse(final String type, final String attributes) { - return "{\"data\":{" - + "\"id\":\"1\"," - + "\"type\":\"" - + type - + "\"," - + "\"attributes\":" - + attributes - + "}}"; - } - - private static String largeConfig(final int flagCount) { - final StringBuilder json = - new StringBuilder( - "{\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," - + "\"environment\":{\"name\":\"Large Test\"}," - + "\"flags\":{"); - for (int index = 0; index < flagCount; index++) { - if (index > 0) { - json.append(','); - } - final String flagKey = "large-flag-" + index; - json.append('"') - .append(flagKey) - .append("\":{\"key\":\"") - .append(flagKey) - .append( - "\",\"enabled\":true,\"variationType\":\"STRING\"," - + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"on\"}}," - + "\"allocations\":[]}"); - } - return jsonApiResponse("universal-flag-configuration", json.append("}}").toString()); - } - - private static byte[] gzip(final String value) throws IOException { - final ByteArrayOutputStream output = new ByteArrayOutputStream(); - try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { - gzip.write(value.getBytes(UTF_8)); - } - return output.toByteArray(); - } - - private static void awaitCalls(final FakeClient client, final int count) throws Exception { - for (int i = 0; i < 100; i++) { - if (client.calls.get() >= count) { - return; - } - TimeUnit.MILLISECONDS.sleep(10); - } - assertEquals(count, client.calls.get()); - } - - private static final class FakeClient implements AgentlessConfigurationSource.UfcHttpClient { - private final AtomicInteger calls = new AtomicInteger(); - private final AtomicInteger cancelCalls = new AtomicInteger(); - private final List requests = new ArrayList<>(); - private final BlockingQueue responses = new LinkedBlockingQueue<>(); - private CountDownLatch requestStarted; - private CountDownLatch releaseRequest; - - private FakeClient(final Object... responses) { - for (final Object response : responses) { - this.responses.add(response); - } - } - - private void block(final CountDownLatch requestStarted, final CountDownLatch releaseRequest) { - this.requestStarted = requestStarted; - this.releaseRequest = releaseRequest; - } - - @Override - public AgentlessConfigurationSource.UfcHttpResponse fetch( - final HttpUrl endpoint, final Config config, final String etag) throws IOException { - calls.incrementAndGet(); - requests.add(new Request(etag)); - if (requestStarted != null) { - requestStarted.countDown(); - } - if (releaseRequest != null) { - await(releaseRequest); - } - final Object response = responses.remove(); - if (response instanceof IOException) { - throw (IOException) response; - } - if (response instanceof RuntimeException) { - throw (RuntimeException) response; - } - return (AgentlessConfigurationSource.UfcHttpResponse) response; - } - - @Override - public void cancel() { - cancelCalls.incrementAndGet(); - if (releaseRequest != null) { - releaseRequest.countDown(); - } - } - - private static void await(final CountDownLatch latch) throws IOException { - try { - if (!latch.await(1, TimeUnit.SECONDS)) { - throw new SocketTimeoutException("test request did not release"); - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException(e); - } - } - } - - private static final class Request { - private final String etag; - - private Request(final String etag) { - this.etag = etag; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java deleted file mode 100644 index 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/FlagEvaluationAggregatorTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java deleted file mode 100644 index 06faeadefea..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java +++ /dev/null @@ -1,425 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.Collections.emptyMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.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; -import org.junit.jupiter.api.Test; - -class FlagEvaluationAggregatorTest { - - @Test - void identicalEventsAggregateIntoOneBucketWithCount2() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 2000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(1, state.fullTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertEquals(1000L, bucket.firstEvalMs); - assertEquals(2000L, bucket.lastEvalMs); - assertTrue(bucket.firstEvalMs <= bucket.lastEvalMs); - } - - @Test - void differentValueTypesProduceDifferentBuckets() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map attrsInt = new HashMap<>(); - attrsInt.put("score", 1); - final Map attrsStr = new HashMap<>(); - attrsStr.put("score", "1"); - - aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsInt)); - aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsStr)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(2, state.fullTier.size()); - } - - @Test - void nulCharactersInKeyFieldsDoNotCollide() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("a\0b", "c", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("a", "b\0c", "alloc1", "user-1", 1000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(2, state.fullTier.size()); - } - - @Test - void globalCapOverflowRoutesToDegradedTier() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.simulateFullTierAtCap(); - aggregator.aggregate(simpleEvent("extra-flag", "on")); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertTrue(state.degradedTier.size() > 0); - assertEquals(0, state.droppedDegradedOverflow); - } - - @Test - void degradedCapOverflowIncrementsDroppedCounter() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.simulateFullTierAtCap(); - aggregator.simulateDegradedTierAtCap(); - aggregator.aggregate(simpleEvent("drop-flag", "on")); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertTrue(state.droppedDegradedOverflow > 0); - } - - @Test - void perFlagCapOverflowRoutesToDegradedTierAndMergesSameDegradedKey() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); - - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(0, state.fullTier.size()); - assertEquals(1, state.degradedTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = - state.degradedTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertEquals(1000L, bucket.firstEvalMs); - assertEquals(2000L, bucket.lastEvalMs); - } - - @Test - void mixedConsentDegradedEvaluationsMergeIntoOneBucket() { - // Mirror of mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets, but for the degraded - // tier: the wire serializer for degraded rows drops the targeting key and context regardless - // of consent, so two events differing only in consent emit byte-identical JSON. They must - // share a bucket, otherwise DEGRADED_CAP is effectively halved for zero wire fidelity gain. - // The AND-fold on EvalBucket.observeFullEvaluationData still runs but the value has no - // downstream effect for degraded rows. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); - - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, false, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(0, state.fullTier.size()); - assertEquals(1, state.degradedTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = - state.degradedTier.values().iterator().next(); - assertEquals(2, bucket.count); - // AND-fold collapses to consent-off; benign for degraded rows but a documented invariant. - assertFalse(bucket.observeFullEvaluationData); - } - - @Test - void absentVariantSetsRuntimeDefaultUsed() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("flag-c", null, "alloc1", "user-1", 1000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(1, state.fullTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertTrue(bucket.runtimeDefaultUsed); - } - - @Test - void aggregatorStoresPrunedAttrsVerbatim() { - // Hot-path hook (DDEvaluator#copyPrunedContext) now delivers an already-pruned map. - // The aggregator no longer re-prunes; it stores what it is given. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map preprunedAttrs = new HashMap<>(); - for (int i = 0; i < 100; i++) { - preprunedAttrs.put("key" + i, "v" + i); - } - - aggregator.aggregate(event("flag-d", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertEquals(100, bucket.prunedContextFieldCount()); - assertEquals(100, bucket.prunedAttrs.size()); - } - - @Test - void emptyContextInputsProduceEmptyCanonicalKey() { - assertEquals("", FlagEvaluationAggregator.canonicalContextKey(null)); - assertEquals("", FlagEvaluationAggregator.canonicalContextKey(emptyMap())); - } - - @Test - void canonicalContextKeyEncodesSupportedValueTypes() { - final Map attrs = new HashMap<>(); - attrs.put("bool", true); - attrs.put("double", 1.5d); - attrs.put("float", 1.25f); - attrs.put("int", 1); - attrs.put("long", 2L); - attrs.put("null", null); - attrs.put("object", new StringBuilder("other")); - attrs.put("string", "value"); - - final String key = FlagEvaluationAggregator.canonicalContextKey(attrs); - - assertEquals(key, FlagEvaluationAggregator.canonicalContextKey(new HashMap<>(attrs))); - assertTrue(key.contains("bool")); - assertTrue(key.contains("string")); - assertTrue(key.contains("other")); - } - - @Test - void aggregatorStoresPrePrunedAttrsWithoutRePruning() { - // Value-length pruning moved to DDEvaluator#copyPrunedContext (hot path). The aggregator - // stores what it is given, so any long value present in the input remains present. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map preprunedAttrs = new HashMap<>(); - preprunedAttrs.put("short-val", "ok"); - - aggregator.aggregate(event("flag-e", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertTrue(bucket.prunedAttrs.containsKey("short-val")); - } - - @Test - void capSizingUsesNamedScaleConstants() { - assertEquals(125_000, FlagEvaluationAggregator.FULL_BUCKET_SIZING_BASIS); - assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_BUCKET_SIZING_BASIS); - assertEquals(25_000, FlagEvaluationAggregator.DEGRADED_BUCKET_SIZING_BASIS); - assertEquals(131_072, FlagEvaluationAggregator.GLOBAL_CAP); - assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_CAP); - assertEquals(32_768, FlagEvaluationAggregator.DEGRADED_CAP); - } - - @Test - void flagEvalEventDoesNotCarryReason() { - final boolean hasReasonField = - Arrays.stream( - datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent.class - .getDeclaredFields()) - .anyMatch(field -> field.getName().equals("reason")); - - assertFalse(hasReasonField); - } - - @Test - void evalBucketTracksBoundsDefaultStateAndNullContextFieldCount() { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false); - - assertEquals(0, bucket.prunedContextFieldCount()); - - bucket.merge(900L, true); - bucket.merge(1100L, false); - bucket.merge(1000L, false); - - assertEquals(4, bucket.count); - assertEquals(900L, bucket.firstEvalMs); - assertEquals(1100L, bucket.lastEvalMs); - assertTrue(bucket.runtimeDefaultUsed); - } - - @Test - void fullKeyEqualityUsesEveryDimension() { - final FlagEvaluationAggregator.FullKey base = - fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); - final FlagEvaluationAggregator.FullKey same = - fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); - - assertEquals(base, base); - assertEquals(base, same); - assertEquals(base.hashCode(), same.hashCode()); - assertNotEquals(base, null); - assertNotEquals(base, "not-a-key"); - assertNotEquals(base, fullKey("other", "on", "alloc", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "off", "alloc", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "other", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", true, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "other", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "other", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "user", "other")); - } - - @Test - void degradedKeyEqualityUsesEveryDimension() { - final FlagEvaluationAggregator.DegradedKey base = - degradedKey("flag", "on", "alloc", false, "error"); - final FlagEvaluationAggregator.DegradedKey same = - degradedKey("flag", "on", "alloc", false, "error"); - - assertEquals(base, base); - assertEquals(base, same); - assertEquals(base.hashCode(), same.hashCode()); - assertNotEquals(base, null); - assertNotEquals(base, "not-a-key"); - assertNotEquals(base, degradedKey("other", "on", "alloc", false, "error")); - assertNotEquals(base, degradedKey("flag", "off", "alloc", false, "error")); - assertNotEquals(base, degradedKey("flag", "on", "other", false, "error")); - assertNotEquals(base, degradedKey("flag", "on", "alloc", true, "error")); - assertNotEquals(base, degradedKey("flag", "on", "alloc", false, "other")); - } - - @Test - void mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets() { - // Consent is part of FullKey: two evaluations that differ only in consent produce different - // wire rows (raw vs hashed targeting key, context vs no context) and belong in different - // buckets. Merging them would silently downgrade the consent-on row to the protected shape. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, false, emptyMap())); - - assertEquals(2, aggregator.fullTierSize()); - int onCount = 0; - int offCount = 0; - for (final FlagEvaluationAggregator.EvalBucket bucket : - aggregator.snapshot().fullTier.values()) { - if (bucket.observeFullEvaluationData) { - onCount++; - } else { - offCount++; - } - } - assertEquals(1, onCount); - assertEquals(1, offCount); - } - - @Test - void sameConsentEvaluationsForSameSubjectMergeIntoOneBucket() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, true, emptyMap())); - - final FlagEvaluationAggregator.EvalBucket bucket = - aggregator.snapshot().fullTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertTrue(bucket.observeFullEvaluationData); - } - - @Test - void protectedPathCollapsesDifferingContextIntoOneBucket() { - // Same subject, different request-id contexts, consent off: the context is dropped on emit so - // it must not fragment full-tier buckets or the per-flag cap blows out under real traffic. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map ctx1 = new HashMap<>(); - ctx1.put("request_id", "req-1"); - final Map ctx2 = new HashMap<>(); - ctx2.put("request_id", "req-2"); - final Map ctx3 = new HashMap<>(); - ctx3.put("request_id", "req-3"); - - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, ctx1)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, false, ctx2)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 3000L, false, ctx3)); - - assertEquals(1, aggregator.fullTierSize()); - final FlagEvaluationAggregator.EvalBucket bucket = - aggregator.snapshot().fullTier.values().iterator().next(); - assertEquals(3, bucket.count); - assertFalse(bucket.observeFullEvaluationData); - assertEquals(0, bucket.prunedContextFieldCount()); - } - - @Test - void protectedPathSeparatesDifferentSubjects() { - // Different targeting keys must still fall into distinct buckets on the protected path — the - // (hashed) targeting key stays part of the aggregation identity. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, emptyMap())); - aggregator.aggregate(event("checkout", "on", "alloc1", "bob", 2000L, false, emptyMap())); - - assertEquals(2, aggregator.fullTierSize()); - } - - @Test - void fullPathStillSplitsBucketsOnDifferingContext() { - // Consent-on preserves the previous behaviour: distinct contexts remain distinct buckets so - // each raw context is emitted verbatim. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map ctx1 = new HashMap<>(); - ctx1.put("plan", "pro"); - ctx1.put("request_id", "req-1"); - final Map ctx2 = new HashMap<>(); - ctx2.put("plan", "pro"); - ctx2.put("request_id", "req-2"); - - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, true, ctx1)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, true, ctx2)); - - assertEquals(2, aggregator.fullTierSize()); - } - - private static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) { - return new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - null, - evalTimeMs, - observeFullEvaluationData, - attrs); - } - - private static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final Map attrs) { - return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); - } - - private static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { - return event(flagKey, variant, "alloc1", "user-1", 1000L, emptyMap()); - } - - private static FlagEvaluationAggregator.FullKey fullKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final String targetingKey, - final String contextKey) { - return new FlagEvaluationAggregator.FullKey( - flagKey, - variant, - allocationKey, - runtimeDefaultUsed, - errorMessage, - targetingKey, - contextKey, - false); - } - - private static FlagEvaluationAggregator.DegradedKey degradedKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage) { - return new FlagEvaluationAggregator.DegradedKey( - flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java deleted file mode 100644 index d4ca517fffd..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java +++ /dev/null @@ -1,453 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.Collections.emptyMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import java.lang.reflect.Type; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class FlagEvaluationPayloadsTest { - - private static final long EVAL_MS = 1_760_000_000_000L; - private static final Map CONTEXT = context(); - private static final JsonAdapter> JSON_MAP; - - static { - final Moshi moshi = new Moshi.Builder().build(); - final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); - JSON_MAP = moshi.adapter(type); - } - - @Test - void fullTierPayloadUsesWorkerWireShape() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event("my-flag", "on", "alloc-x", "user-1", 1, attrs)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - assertObjectWithKey(ev.get("variant"), "on"); - assertObjectWithKey(ev.get("allocation"), "alloc-x"); - assertObjectWithKey(ev.get("flag"), "my-flag"); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - assertFalse(ev.containsKey("reason")); - } - - @Test - void eventFromFullBucketUsesFlushTimeAndEvaluationBounds() throws Exception { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "ts-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, emptyMap(), true); - bucket.merge(EVAL_MS + 10, false); - final long flushTimeMs = EVAL_MS + 5_000; - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, true, flushTimeMs)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - assertEquals((double) flushTimeMs, ((Number) ev.get("timestamp")).doubleValue()); - assertEquals((double) EVAL_MS, ((Number) ev.get("first_evaluation")).doubleValue()); - assertEquals((double) (EVAL_MS + 10), ((Number) ev.get("last_evaluation")).doubleValue()); - assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); - } - - @Test - void degradedTierEventOmitsTargetingKeyAndContext() throws Exception { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "dg-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, false, true, EVAL_MS)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void fullTierWithObserveFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - null, - EVAL_MS, - false, - attrs, - true); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, true, EVAL_MS)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - } - - @Test - void fullTierWithObserveFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() - throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - null, - EVAL_MS, - false, - attrs, - false); - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, false, EVAL_MS)), - CONTEXT, - 1_000_000); - final String rawJson = - new String(payloads.bodies.get(0), java.nio.charset.StandardCharsets.UTF_8); - - // The raw wire bytes must carry the hashed key and must not leak the raw PII value or the - // per-event evaluation context — these are the exact properties system-tests asserts over the - // wire. (The batch envelope has its own top-level "context" field, so we guard on the nested - // "evaluation" key instead, which only appears inside a per-event context object.) - assertTrue( - rawJson.contains( - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b")); - assertFalse(rawJson.contains("jane.doe@datadoghq.com")); - assertFalse(rawJson.contains("\"evaluation\":")); - - final Map ev = firstEvent(parse(payloads.bodies.get(0))); - assertEquals( - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b", - ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - } - - @Test - void splitPayloadsByEncodedSize() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 180)); - final java.util.ArrayList events = - new java.util.ArrayList<>(); - for (int i = 0; i < 4; i++) { - events.add(event("split-flag-" + i, "on", "alloc1", "user-" + i, 1, attrs)); - } - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(events, CONTEXT, 1_100); - - assertTrue(payloads.bodies.size() > 1); - int eventCount = 0; - for (final byte[] body : payloads.bodies) { - assertTrue(body.length <= 1_100); - eventCount += eventCount(parse(body)); - } - assertEquals(4, eventCount); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void oversizedFullPayloadRowIsDegradedBeforeDrop() throws Exception { - final Map attrs = new HashMap<>(); - for (int i = 0; i < 4; i++) { - attrs.put("payload-" + i, repeat('x', 200)); - } - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event("oversized-full", "on", "alloc1", "user-1", 2, attrs)), - CONTEXT, - 512); - - assertEquals(1, payloads.bodies.size()); - assertTrue(payloads.bodies.get(0).length <= 512); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(2, payloads.degradedPayloadLimit); - final Map ev = firstEvent(parse(payloads.bodies.get(0))); - assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void oversizedFullPayloadRowStartsNewPayloadWhenDegradedRowFitsByItself() throws Exception { - final FlagEvaluationPayloads.FlagEvaluationEvent first = - event("first-flag", "on", "alloc1", "user-1", 1, emptyMap()); - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 400)); - final FlagEvaluationPayloads.FlagEvaluationEvent second = - event("second-flag", "on", "alloc1", "user-2", 3, attrs); - final FlagEvaluationPayloads.FlagEvaluationEvent degradedSecond = - second.withoutTargetingKeyAndContext(); - assertNotNull(degradedSecond); - - final int firstPayloadSize = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList(first), CONTEXT, 1_000_000) - .bodies - .get(0) - .length; - final int degradedPayloadSize = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList(degradedSecond), CONTEXT, 1_000_000) - .bodies - .get(0) - .length; - final int limit = Math.max(firstPayloadSize, degradedPayloadSize); - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(Arrays.asList(first, second), CONTEXT, limit); - - assertEquals(2, payloads.bodies.size()); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(3, payloads.degradedPayloadLimit); - assertEquals(1, eventCount(parse(payloads.bodies.get(0)))); - final Map ev = firstEvent(parse(payloads.bodies.get(1))); - assertObjectWithKey(ev.get("flag"), "second-flag"); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void oversizedDegradedPayloadRowIsDropped() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event(repeat('f', 512), "on", "alloc1", null, 2, emptyMap())), - CONTEXT, - 128); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(2, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void oversizedFullPayloadRowIsDroppedWhenDegradedRowStillExceedsLimit() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event(repeat('f', 512), "on", "alloc1", "user-1", 2, emptyMap())), - CONTEXT, - 128); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(2, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void errorPayloadSerializesErrorObject() throws Exception { - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - "err-flag", - EVAL_MS, - EVAL_MS, - 1, - null, - null, - null, - true, - "type mismatch", - null)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - final Map error = (Map) ev.get("error"); - assertNotNull(error); - assertEquals("type mismatch", error.get("message")); - assertEquals(Boolean.TRUE, ev.get("runtime_default_used")); - } - - @Test - void emptyEventListProducesNoPayloads() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(java.util.Collections.emptyList(), CONTEXT, 1_000_000); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void requestDtoStoresContextAndEvents() { - final List events = - java.util.Collections.singletonList( - event("dto-flag", "on", "alloc1", "user-1", 1, emptyMap())); - - final FlagEvaluationPayloads.FlagEvaluationsRequest request = - new FlagEvaluationPayloads.FlagEvaluationsRequest(CONTEXT, events); - - assertEquals(CONTEXT, request.context); - assertEquals(events, request.flagEvaluations); - } - - @Test - void degradingEventWithMissingOptionalFieldsKeepsOptionalObjectsAbsent() { - final FlagEvaluationPayloads.FlagEvaluationEvent degraded = - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - "default-flag", - EVAL_MS, - EVAL_MS, - 1, - null, - null, - "user-1", - true, - null, - null) - .withoutTargetingKeyAndContext(); - - assertNotNull(degraded); - assertNull(degraded.variant); - assertNull(degraded.allocation); - assertNull(degraded.targeting_key); - assertNull(degraded.context); - assertNull(degraded.error); - assertEquals(Boolean.TRUE, degraded.runtime_default_used); - } - - @Test - void emptyOptionalStringsAreTreatedAsAbsentWhenContextIsPresent() { - final Map attrs = new HashMap<>(); - attrs.put("tier", "gold"); - final FlagEvaluationPayloads.FlagEvaluationEvent event = - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, "empty-optionals", EVAL_MS, EVAL_MS, 1, "", "", null, false, "", attrs); - - assertNull(event.variant); - assertNull(event.allocation); - assertNull(event.error); - assertNotNull(event.context); - - final FlagEvaluationPayloads.FlagEvaluationEvent degraded = - event.withoutTargetingKeyAndContext(); - assertNotNull(degraded); - assertNull(degraded.targeting_key); - assertNull(degraded.context); - } - - private static FlagEvaluationPayloads.FlagEvaluationEvent event( - final String flagKey, - final String variant, - final String allocation, - final String targetingKey, - final long count, - final Map attrs) { - return new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - flagKey, - EVAL_MS, - EVAL_MS, - count, - variant, - allocation, - targetingKey, - false, - null, - attrs); - } - - private static Map firstPayload( - final FlagEvaluationPayloads.EncodedPayloads payloads) throws Exception { - assertEquals(1, payloads.bodies.size()); - return parse(payloads.bodies.get(0)); - } - - private static Map parse(final byte[] body) throws Exception { - return JSON_MAP.fromJson(new String(body, java.nio.charset.StandardCharsets.UTF_8)); - } - - @SuppressWarnings("unchecked") - private static Map firstEvent(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events); - assertFalse(events.isEmpty()); - return (Map) events.get(0); - } - - @SuppressWarnings("unchecked") - private static int eventCount(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events); - return events.size(); - } - - private static void assertObjectWithKey(final Object object, final String expectedKey) { - assertTrue(object instanceof Map); - assertEquals(expectedKey, ((Map) object).get("key")); - } - - private static String repeat(final char c, final int count) { - final char[] chars = new char[count]; - java.util.Arrays.fill(chars, c); - return new String(chars); - } - - private static Map context() { - final Map context = new HashMap<>(); - context.put("service", "test-service"); - return context; - } -} 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/JsonApiUfcResponseParserTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java deleted file mode 100644 index fae2631ce2f..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ /dev/null @@ -1,463 +0,0 @@ -package com.datadog.featureflag; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.squareup.moshi.JsonQualifier; -import com.squareup.moshi.Moshi; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import java.io.IOException; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.Collections; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -class JsonApiUfcResponseParserTest { - - @Test - void parsesJsonApiMembersInAnyOrder() throws Exception { - final ServerConfiguration configuration = - parse( - "{" - + "\"meta\":{\"ignored\":true}," - + "\"data\":{" - + "\"attributes\":" - + emptyConfig() - + ",\"ignored\":true," - + "\"type\":\"universal-flag-configuration\"" - + "}" - + "}"); - - assertNotNull(configuration); - assertEquals("Test", configuration.environment.name); - assertTrue(configuration.flags.isEmpty()); - } - - @Test - void rejectsRawUfc() throws Exception { - assertNull(parse(emptyConfig())); - } - - @Test - void rejectsUnexpectedJsonApiType() throws Exception { - assertNull(parse("{\"data\":{\"type\":\"other-type\",\"attributes\":" + emptyConfig() + "}}")); - } - - @Test - void rejectsNonStringJsonApiType() throws Exception { - assertNull(parse("{\"data\":{\"type\":null,\"attributes\":" + emptyConfig() + "}}")); - } - - @Test - void rejectsConfigurationWithoutFlags() throws Exception { - assertNull( - parse( - "{\"data\":{" - + "\"type\":\"universal-flag-configuration\"," - + "\"attributes\":{\"environment\":{\"name\":\"Test\"}}" - + "}}")); - } - - @Test - void rejectsNonObjectData() throws Exception { - assertNull(parse("{\"data\":[]}")); - } - - @Test - void rejectsTrailingJson() { - assertThrows( - IOException.class, - () -> - parse( - "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" - + emptyConfig() - + "}}{}")); - } - - @Test - void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag("no-allocations", ""), - booleanFlag( - "no-splits", ",\"allocations\":[{\"key\":\"no-splits\",\"rules\":[]}]"), - booleanFlag( - "null-split", - ",\"allocations\":[{\"key\":\"null-split\",\"rules\":[],\"splits\":[null]}]"), - booleanFlag("no-rules", allocation("no-rules", "")), - booleanFlag("no-conditions", allocation("no-conditions", "[{}]")), - booleanFlag( - "no-operator", allocation("no-operator", "[{\"conditions\":[{}]}]")), - booleanFlag( - "non-semver", - allocation( - "non-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"1\"}]}]")), - booleanFlag( - "valid-semver", - allocation( - "valid-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.2\"}]}]")), - booleanFlag( - "invalid-semver", - allocation( - "invalid-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.02\"}]}]")), - booleanFlag( - "non-string-semver", - allocation( - "non-string-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":1}]}]")), - "\"null-flag\":null"))); - - assertNotNull(configuration); - assertTrue(configuration.flags.containsKey("no-allocations")); - assertTrue(configuration.flags.containsKey("no-splits")); - assertTrue(configuration.flags.containsKey("null-split")); - assertTrue(configuration.flags.containsKey("no-rules")); - assertTrue(configuration.flags.containsKey("no-conditions")); - assertTrue(configuration.flags.containsKey("no-operator")); - assertTrue(configuration.flags.containsKey("non-semver")); - assertTrue(configuration.flags.containsKey("valid-semver")); - assertFalse(configuration.flags.containsKey("invalid-semver")); - assertFalse(configuration.flags.containsKey("non-string-semver")); - assertFalse(configuration.flags.containsKey("null-flag")); - assertEquals(2, configuration.invalidFlags.size()); - assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); - assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); - - assertNotNull( - configuration - .flags - .get("valid-semver") - .allocations - .get(0) - .rules - .get(0) - .conditions - .get(0) - .semverComparand); - } - - @Test - void dropsFlagWithMalformedAllocationsWithoutRejectingConfig() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag("malformed-allocations", ",\"allocations\":\"not-a-list\""), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("malformed-allocations")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("malformed-allocations")); - } - - @Test - void dropsFlagWithMissingSplitShards() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( - "missing-shards", - ",\"allocations\":[{\"key\":\"missing-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\"}]}]"), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("missing-shards")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("missing-shards")); - } - - @Test - void dropsFlagsWithInvalidConditionOperandsAndShardBounds() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( - "non-numeric-gt", - allocation( - "non-numeric-gt", - "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"GT\",\"value\":\"bad\"}]}]")), - booleanFlag( - "non-list-one-of", - allocation( - "non-list-one-of", - "[{\"conditions\":[{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":\"admin\"}]}]")), - booleanFlag( - "negative-shard-range", - ",\"allocations\":[{\"key\":\"negative-shard-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":100,\"ranges\":[{\"start\":-1,\"end\":1}]}]}]}]"), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("non-numeric-gt")); - assertFalse(configuration.flags.containsKey("non-list-one-of")); - assertFalse(configuration.flags.containsKey("negative-shard-range")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("non-numeric-gt")); - assertEquals("invalid_flag", configuration.invalidFlags.get("non-list-one-of")); - assertEquals("invalid_flag", configuration.invalidFlags.get("negative-shard-range")); - } - - @Test - void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( - "zero-total-shards", - ",\"allocations\":[{\"key\":\"zero-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":0,\"ranges\":[]}]}]}]"), - booleanFlag( - "unsigned-total-shards", - ",\"allocations\":[{\"key\":\"unsigned-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":2147483648,\"ranges\":[{\"start\":2147483648,\"end\":2147483649}]}]}]}]"), - booleanFlag( - "too-many-shards", - ",\"allocations\":[{\"key\":\"too-many-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":4294967296,\"ranges\":[]}]}]}]"), - booleanFlag( - "missing-ranges", - ",\"allocations\":[{\"key\":\"missing-ranges\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1}]}]}]"), - booleanFlag( - "null-shard", - ",\"allocations\":[{\"key\":\"null-shard\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[null]}]}]"), - booleanFlag( - "null-range", - ",\"allocations\":[{\"key\":\"null-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[null]}]}]}]"), - booleanFlag( - "negative-range-end", - ",\"allocations\":[{\"key\":\"negative-range-end\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[{\"start\":0,\"end\":-1}]}]}]}]"), - booleanFlag( - "non-boolean-is-null", - allocation( - "non-boolean-is-null", - "[{\"conditions\":[{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":\"false\"}]}]")), - booleanFlag( - "valid-condition-operands", - allocation( - "valid-condition-operands", - "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"LT\",\"value\":1},{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":[\"admin\"]},{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":true}]}]"))))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("zero-total-shards")); - assertTrue(configuration.flags.containsKey("unsigned-total-shards")); - assertEquals( - 2_147_483_648L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .totalShards)); - assertEquals( - 2_147_483_648L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .ranges - .get(0) - .start)); - assertEquals( - 2_147_483_649L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .ranges - .get(0) - .end)); - assertFalse(configuration.flags.containsKey("too-many-shards")); - assertFalse(configuration.flags.containsKey("missing-ranges")); - assertFalse(configuration.flags.containsKey("null-shard")); - assertFalse(configuration.flags.containsKey("null-range")); - assertFalse(configuration.flags.containsKey("negative-range-end")); - assertFalse(configuration.flags.containsKey("non-boolean-is-null")); - assertTrue(configuration.flags.containsKey("valid-condition-operands")); - } - - @Test - void shardAdapterFactoryRejectsQualifiedShard() { - assertNull( - UniversalFlagConfigParser.ShardAdapter.FACTORY.create( - Shard.class, - Collections.singleton(QualifiedShard.class.getAnnotation(ShardQualifier.class)), - new Moshi.Builder().build())); - } - - @JsonQualifier - @Retention(RetentionPolicy.RUNTIME) - @Target(ElementType.TYPE) - private @interface ShardQualifier {} - - @ShardQualifier - private static final class QualifiedShard {} - - @Test - void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { - assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}")); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { - // Absent → Moshi leaves the boxed field null; the read site's Boolean.TRUE.equals(...) then - // resolves to the privacy-preserving default (consent-off). Either null-or-false is the - // documented invariant; assert the field never reads as true. - final ServerConfiguration configuration = parse(wrap(emptyConfig())); - assertNotNull(configuration); - assertFalse(Boolean.TRUE.equals(configuration.observeFullEvaluationData)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void observeFullEvaluationDataParsesExplicitValue(final boolean value) throws Exception { - final ServerConfiguration configuration = - parse(wrap(configWithObserveFullEvaluationData(value))); - assertNotNull(configuration); - assertEquals(value, configuration.observeFullEvaluationData); - } - - @Test - void observeFullEvaluationDataExplicitNullDefaultsToFalseWithoutRejectingConfig() - throws Exception { - // An explicit null (or a wrong-typed value) for this field must not abort the whole UFC parse. - // A pod that starts after a malformed UFC has no last-known-good, so aborting would strand - // every flag on PROVIDER_NOT_READY (its default value). We fail closed on privacy (consent - // stays false) but preserve availability: flags parse and evaluate. - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("null"))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - @Test - void observeFullEvaluationDataWrongTypedStringDefaultsToFalse() throws Exception { - // Moshi tolerates a stringified boolean like "true" via nullSafe/boxed handling: it either - // parses as null or throws locally and leaves the field null. Either way, downstream reads - // via Boolean.TRUE.equals(...) treat it as consent-off. The rest of the config must parse. - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("\"true\""))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - @Test - void observeFullEvaluationDataWrongTypedNumberDefaultsToFalse() throws Exception { - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("1"))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - private static ServerConfiguration parse(final String json) throws Exception { - return JsonApiUfcResponseParser.INSTANCE.parse(json.getBytes(UTF_8)); - } - - private static String wrap(final String attributes) { - return "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" - + attributes - + "}}"; - } - - private static String configWithObserveFullEvaluationData(final boolean value) { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"observeFullEvaluationData\":" - + value - + "," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } - - private static String configWithRawObserveFullEvaluationData(final String rawJsonValue) { - // Emit the field with a caller-controlled raw JSON value (null / "true" / 1 / ...) so we can - // assert the parser's tolerance of malformed shapes without going through configWith's - // boolean-typed helper. - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"observeFullEvaluationData\":" - + rawJsonValue - + "," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } - - private static String configWithFlags(final String... flags) { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{" - + String.join(",", flags) - + "}}"; - } - - private static String booleanFlag(final String key, final String allocations) { - return "\"" - + key - + "\":{" - + "\"key\":\"" - + key - + "\",\"enabled\":true,\"variationType\":\"BOOLEAN\"," - + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":true}}" - + allocations - + "}"; - } - - private static String allocation(final String key, final String rules) { - return ",\"allocations\":[{\"key\":\"" - + key - + "\"" - + (rules.isEmpty() ? "" : ",\"rules\":" + rules) - + ",\"splits\":[]}]"; - } - - private static String emptyConfig() { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java deleted file mode 100644 index 7d43c7a8b51..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java +++ /dev/null @@ -1,311 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.Collections.emptyMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import datadog.trace.api.featureflag.exposure.Allocation; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.Flag; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.exposure.Variant; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -class LRUExposureCacheTest { - - @Test - void testAddingElements() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - boolean added = cache.add(event); - - assertTrue(added); - assertEquals(1, cache.size()); - } - - @Test - void testAddingDuplicateEventsReturnsFalse() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - cache.add(event); - boolean duplicateAdded = cache.add(event); - - assertFalse(duplicateAdded); - assertEquals(1, cache.size()); - } - - @Test - void testAddingEventsWithSameKeyButDifferentDetailsUpdatesCache() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event1 = createEvent("flag", "subject", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag", "subject", "variant2", "allocation2"); - ExposureCache.Key key = new ExposureCache.Key(event1); - - boolean added1 = cache.add(event1); - boolean added2 = cache.add(event2); - ExposureCache.Value retrieved = cache.get(key); - - assertTrue(added1); - assertTrue(added2); - assertEquals(1, cache.size()); - assertEquals("variant2", retrieved.variant); - assertEquals("allocation2", retrieved.allocation); - } - - @Test - void testSerialIdAppearingEmitsANewExposure() { - assertEquals(2, emissions(null, 7)); - } - - @Test - void testSerialIdDisappearingEmitsANewExposure() { - assertEquals(2, emissions(7, null)); - } - - @Test - void testSerialIdChangingEmitsANewExposure() { - assertEquals(2, emissions(7, 8)); - } - - @Test - void testSerialIdCycleEmitsAnExposurePerTransition() { - assertEquals(3, emissions(7, 8, 7)); - } - - @Test - void testUnchangedSerialIdIsDeduplicated() { - assertEquals(1, emissions(7, 7)); - } - - @Test - void testUnchangedAbsentSerialIdIsDeduplicated() { - assertEquals(1, emissions(null, null)); - } - - @Test - void testSerialIdZeroIsDistinguishedFromAbsent() { - assertEquals(2, emissions(null, 0)); - assertEquals(2, emissions(0, null)); - } - - @Test - void testSerialIdIsRetainedOnTheCachedValue() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation", 0); - - cache.add(event); - - assertEquals(Integer.valueOf(0), cache.get(new ExposureCache.Key(event)).serialId); - } - - @Test - void testLruEvictionWhenCapacityExceeded() { - LRUExposureCache cache = new LRUExposureCache(2); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key3 = new ExposureCache.Key(event3); - - cache.add(event1); - cache.add(event2); - cache.add(event3); - - assertEquals(2, cache.size()); - assertNull(cache.get(key1)); // event1 should be evicted - assertNotNull(cache.get(key3)); // event3 should be present - assertEquals("variant3", cache.get(key3).variant); - assertEquals("allocation3", cache.get(key3).allocation); - } - - @Test - void testSingleCapacityCache() { - LRUExposureCache cache = new LRUExposureCache(1); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - - cache.add(event1); - cache.add(event2); - - assertEquals(1, cache.size()); - } - - @Test - void testZeroCapacityCache() { - LRUExposureCache cache = new LRUExposureCache(0); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - boolean added = cache.add(event); - - assertTrue(added); - assertEquals(0, cache.size()); - } - - @Test - void testEmptyCacheSize() { - LRUExposureCache cache = new LRUExposureCache(5); - - assertEquals(0, cache.size()); - } - - @Test - void testMultipleAdditionsWithSameFlagDifferentSubjects() { - LRUExposureCache cache = new LRUExposureCache(10); - List events = new ArrayList<>(); - for (int index = 0; index < 5; index++) { - events.add(createEvent("flag", "subject" + index, "variant", "allocation")); - } - - for (ExposureEvent event : events) { - assertTrue(cache.add(event)); - } - - assertEquals(5, cache.size()); - } - - @Test - void testMultipleAdditionsWithSameSubjectDifferentFlags() { - LRUExposureCache cache = new LRUExposureCache(10); - List events = new ArrayList<>(); - for (int index = 0; index < 5; index++) { - events.add(createEvent("flag" + index, "subject", "variant", "allocation")); - } - - for (ExposureEvent event : events) { - assertTrue(cache.add(event)); - } - - assertEquals(5, cache.size()); - } - - @Test - void testKeyEqualityWithNullValues() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event1 = - new ExposureEvent( - System.currentTimeMillis(), - new Allocation("allocation"), - new Flag(null), - new Variant("variant"), - new Subject(null, emptyMap())); - ExposureEvent event2 = - new ExposureEvent( - System.currentTimeMillis(), - new Allocation("allocation"), - new Flag(null), - new Variant("variant"), - new Subject(null, emptyMap())); - - cache.add(event1); - boolean duplicateAdded = cache.add(event2); - - assertFalse(duplicateAdded); - assertEquals(1, cache.size()); - } - - @Test - void testUpdatingExistingKeyMaintainsLruPosition() { - LRUExposureCache cache = new LRUExposureCache(3); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - ExposureEvent event1Updated = createEvent("flag1", "subject1", "variant2", "allocation2"); - ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key2 = new ExposureCache.Key(event2); - ExposureCache.Key key4 = new ExposureCache.Key(event4); - - cache.add(event1); - cache.add(event2); - cache.add(event3); - cache.add(event1Updated); // Updates event1, moves to most recent - cache.add(event4); // Should evict event2, not event1 - - assertEquals(3, cache.size()); - assertNotNull(cache.get(key1)); // event1 should be updated and present - assertEquals("variant2", cache.get(key1).variant); // verify it was updated - assertEquals("allocation2", cache.get(key1).allocation); - assertNull(cache.get(key2)); // event2 should be evicted - assertNotNull(cache.get(key4)); // event4 should be present - assertEquals("variant4", cache.get(key4).variant); - } - - @Test - void testDuplicateExposureKeepsSubjectHotInLruOrder() { - LRUExposureCache cache = new LRUExposureCache(3); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - // same key + same details as event1: will go through the "duplicate" path - ExposureEvent event1Duplicate = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); - - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key2 = new ExposureCache.Key(event2); - ExposureCache.Key key4 = new ExposureCache.Key(event4); - - // Fill cache - boolean added1 = cache.add(event1); - boolean added2 = cache.add(event2); - boolean added3 = cache.add(event3); - - // Duplicate exposure for subject1: should *not* change size, but *should* bump recency - boolean duplicateAdded = cache.add(event1Duplicate); - - // Now push over capacity: the least recently used *non-hot* entry (event2) should be evicted - boolean added4 = cache.add(event4); - - assertTrue(added1); - assertTrue(added2); - assertTrue(added3); - assertFalse(duplicateAdded); // dedup correctly - assertTrue(added4); - - assertEquals(3, cache.size()); - - assertNotNull(cache.get(key1)); // hot subject1 should still be present - assertNull(cache.get(key2)); // subject2 should be evicted - assertNotNull(cache.get(key4)); // newest subject4 should be present - - assertEquals("variant1", cache.get(key1).variant); - assertEquals("allocation1", cache.get(key1).allocation); - } - - private static ExposureEvent createEvent( - String flag, String subject, String variant, String allocation) { - return createEvent(flag, subject, variant, allocation, null); - } - - private static ExposureEvent createEvent( - String flag, String subject, String variant, String allocation, Integer serialId) { - return new ExposureEvent( - System.currentTimeMillis(), - new Allocation(allocation), - new Flag(flag), - new Variant(variant), - new Subject(subject, emptyMap()), - serialId); - } - - /** - * Number of exposures a run of same-flag, same-subject evaluations emits: the cache reports true - * only for the ones it does not suppress as duplicates. - */ - private static int emissions(final Integer... serialIds) { - LRUExposureCache cache = new LRUExposureCache(5); - int emitted = 0; - for (Integer serialId : serialIds) { - if (cache.add(createEvent("flag", "subject", "variant", "allocation", serialId))) { - emitted++; - } - } - return emitted; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index f11816a9bc0..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,540 +1,120 @@ package com.datadog.featureflag; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.emptySet; -import static java.util.Collections.singleton; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; import datadog.remoteconfig.ConfigurationDeserializer; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.PollingRateHinter; import datadog.remoteconfig.Product; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.Feature; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.IOException; -import java.io.InputStream; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.util.Arrays; -import java.util.Date; +import java.util.ArrayList; import java.util.List; -import java.util.Map; -import okio.Buffer; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.tabletest.junit.TableTest; -@ExtendWith(MockitoExtension.class) class RemoteConfigServiceImplTest { - - @Mock private FeatureFlaggingGateway.ConfigListener listener; - @Captor private ArgumentCaptor deserializerCaptor; - - @AfterEach - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener); - } + private static final byte[] DOCUMENT = "{\"flags\":{}}".getBytes(UTF_8); @Test - void testNewConfigReceived() throws Exception { + @SuppressWarnings("unchecked") + void subscriptionRegistersTheProductWithARawDeserializer() throws Exception { final ConfigurationPoller poller = mock(ConfigurationPoller.class); - final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); - when(sco.configurationPoller(any(Config.class))).thenReturn(poller); - FeatureFlaggingGateway.addConfigListener(listener); - final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(sco, Config.get()); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); - service.init(); + service.subscribe(content -> {}); + service.subscribe(content -> {}); verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), deserializerCaptor.capture(), eq(service)); - - final ServerConfiguration config = deserializer().deserialize(emptyConfig().getBytes(UTF_8)); - service.accept("test", config, mock(PollingRateHinter.class)); - - verify(listener).accept(any(ServerConfiguration.class)); - - service.close(); - - verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).removeListeners(Product.FFE_FLAGS); - } - - @Test - void skipsMalformedFlagAllocationsAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("malformed-allocations.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("malformed-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void parsesSplitSerialId() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("340132")); - - assertNotNull(config); - assertEquals(Integer.valueOf(340132), serialIdOf(config)); - } - - @Test - void parsesSplitSerialIdZero() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("0")); - - assertNotNull(config); - assertEquals(Integer.valueOf(0), serialIdOf(config)); - } - - @Test - void parsesAbsentSplitSerialIdAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId(null)); - - assertNotNull(config); - assertNull(serialIdOf(config)); - } - - @Test - void parsesNullSplitSerialIdAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("null")); - - assertNotNull(config); - assertNull(serialIdOf(config)); - } - - @Test - void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { - final ServerConfiguration config = deserialize(configWithSiblingSerialIds("true")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("malformed-flag")); - assertEquals("invalid_flag", config.invalidFlags.get("malformed-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - /** - * Records how leniently the per-flag value reader coerces a serial id, so a future change to the - * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no - * validation of its own; what matters is that a bad one never rejects the sibling flag. - */ - @ParameterizedTest - @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) - void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) - throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); - - assertNotNull(config); - assertEquals(Integer.valueOf(expected), serialIdOf(config)); - } - - @Test - void parsesSplitFeatures() throws Exception { - final ServerConfiguration config = - deserialize( - configWithFeatures( - "[{\"key\": \"holdout.key\", \"value\": \"q4-global\", \"destinations\": [\"HOOK\"]}," - + " {\"key\": \"holdout.weight\", \"value\": 0.5," - + " \"destinations\": [\"EXPOSURE\", \"EVALUATION\"]}," - + " {\"key\": \"holdout.should_include_in_holdout_analysis\", \"value\": true," - + " \"destinations\": [\"HOOK\", \"SOME_FUTURE_DESTINATION\"]}]")); - - final List features = featuresOf(config); - assertEquals(3, features.size()); - assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); - assertFeature(features.get(1), "holdout.weight", 0.5, "EXPOSURE", "EVALUATION"); - assertFeature( - features.get(2), - "holdout.should_include_in_holdout_analysis", - true, - "HOOK", - "SOME_FUTURE_DESTINATION"); - assertEquals(Integer.valueOf(7), serialIdOf(config)); + 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 parsesAbsentSplitFeaturesAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithFeatures(null)); - - assertNull(featuresOf(config)); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } + 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); - @Test - void dropsMalformedSplitFeaturesAndKeepsTheFlag() throws Exception { - final String hook = ", \"destinations\": [\"HOOK\"]"; - final ServerConfiguration config = - deserialize( - configWithFeatures( - "[{\"key\": \"object-value\", \"value\": {\"nested\": true}" - + hook - + "}," - + " {\"key\": \"array-value\", \"value\": [1]" - + hook - + "}," - + " {\"key\": \"null-value\", \"value\": null" - + hook - + "}," - + " {\"key\": \"\", \"value\": \"empty key\"" - + hook - + "}," - + " {\"key\": 3, \"value\": \"numeric key\"" - + hook - + "}," - + " {\"value\": \"missing key\"" - + hook - + "}," - + " {\"key\": \"missing value\"" - + hook - + "}," - + " {\"key\": \"no-destinations\", \"value\": \"dropped\"}," - + " {\"key\": \"empty-destinations\", \"value\": \"dropped\", \"destinations\": []}," - + " {\"key\": \"destinations-not-a-list\", \"value\": \"dropped\"," - + " \"destinations\": \"HOOK\"}," - + " {\"key\": \"only-invalid-destinations\", \"value\": \"dropped\"," - + " \"destinations\": [1, \"\", null, {\"name\": \"HOOK\"}]}," - + " \"not-an-object\"," - + " {\"key\": \"holdout.key\", \"value\": \"q4-global\"," - + " \"destinations\": [1, \"HOOK\", \"\"], \"destination\": \"HOOK\"}]")); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); + service.accept("key", null, mock(PollingRateHinter.class)); - assertTrue(config.flags.containsKey("valid-flag")); - final List features = featuresOf(config); - assertEquals(1, features.size()); - assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); + assertEquals(2, first.size()); + assertArrayEquals(DOCUMENT, first.get(0)); + assertNull(first.get(1)); + assertEquals(2, second.size()); } @Test - void ignoresSplitFeaturesThatAreNotAList() throws Exception { - final ServerConfiguration config = - deserialize(configWithFeatures("{\"holdout.key\": \"q4-global\"}")); + void replaysTheCurrentDocumentToLateSubscribers() { + final RemoteConfigServiceImpl service = + new RemoteConfigServiceImpl(mock(ConfigurationPoller.class)); + service.subscribe(content -> {}); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); - assertTrue(config.flags.containsKey("valid-flag")); - assertNull(featuresOf(config)); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - private static void assertFeature( - final Feature feature, final String key, final Object value, final String... destinations) { - assertEquals(key, feature.key); - assertEquals(value, feature.value); - assertEquals(Arrays.asList(destinations), feature.destinations); - } + final List late = new ArrayList<>(); + service.subscribe(late::add); - private static List featuresOf(final ServerConfiguration config) { - return config.flags.get("valid-flag").allocations.get(0).splits.get(0).features; - } - - /** Inserts the raw features JSON; null omits the key. */ - private static String configWithFeatures(final String featuresJson) throws IOException { - final String json = resource("split-features.json"); - return featuresJson == null - ? json.replace("\"features\": \"${features}\",", "") - : json.replace("\"${features}\"", featuresJson); - } - - private static Integer serialIdOf(final ServerConfiguration config) { - return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; - } - - private static String configWithSerialId(final String serialIdJson) throws IOException { - return withSerialId(resource("serial-id.json"), serialIdJson); - } - - /** A malformed serial id must bind to its own flag and leave the sibling flag intact. */ - private static String configWithSiblingSerialIds(final String malformedSerialIdJson) - throws IOException { - return withSerialId(resource("sibling-serial-ids.json"), malformedSerialIdJson); - } - - /** Inserts the raw wire value so the parser sees its original JSON type; null omits the key. */ - private static String withSerialId(final String json, final String serialIdJson) { - return serialIdJson == null - ? json.replace("\"serialId\": \"${serialId}\",", "") - : json.replace("\"${serialId}\"", serialIdJson); + assertEquals(1, late.size()); + assertArrayEquals(DOCUMENT, late.get(0)); } @Test - void ignoresUnknownTopLevelFields() throws Exception { - final ServerConfiguration config = deserialize(resource("unknown-top-level-fields.json")); - - assertNotNull(config); - assertEquals("2024-04-17T19:40:53.716Z", config.createdAt); - assertEquals("SERVER", config.format); - assertNotNull(config.environment); - assertEquals("Test", config.environment.name); - assertTrue(config.flags.isEmpty()); - } - - @Test - void parsesAllocationWindowDatesAsDateFieldsWithInstantAccessors() throws Exception { - final ServerConfiguration config = deserialize(resource("allocation-window-dates.json")); - - final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); - assertEquals(Date.class, Allocation.class.getField("startAt").getType()); - assertEquals(Date.class, Allocation.class.getField("endAt").getType()); - assertEquals(Instant.parse("2023-01-01T00:00:00.123Z"), allocation.startAt.toInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00.987Z"), allocation.endAt.toInstant()); - assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAtInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAtInstant()); - } - - @Test - void rejectsTrailingJson() { - assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); - } - - @Test - void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("unknown-operator.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("operator-grease-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void flagMapAdapterFactoryOnlyCreatesFlagMapAdapterForFlagMapType() { - final Moshi moshi = moshi(); - final Type flagsType = Types.newParameterizedType(Map.class, String.class, Flag.class); - - final JsonAdapter adapter = - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(flagsType, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.FlagMapAdapter); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(String.class, emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create( - flagsType, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void lenientBooleanAdapterFactoryOnlyCreatesAdapterForUnannotatedBoxedBoolean() { - final Moshi moshi = moshi(); - - final JsonAdapter adapter = - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - Boolean.class, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.LenientBooleanAdapter); - // Primitive boolean keeps Moshi's strict adapter so mandatory fields still reject bad values. - assertNull( - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - boolean.class, emptySet(), moshi)); - // A qualified Boolean belongs to whichever adapter declared the qualifier, not to this one. - assertNull( - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - Boolean.class, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void lenientBooleanAdapterIsReadOnly() { - final UniversalFlagConfigParser.LenientBooleanAdapter adapter = - new UniversalFlagConfigParser.LenientBooleanAdapter(); - - assertThrows( - UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), true)); - } - - @Test - void featureListAdapterFactoryOnlyCreatesAdapterForUnannotatedFeatureList() { - final Moshi moshi = moshi(); - final Type featuresType = Types.newParameterizedType(List.class, Feature.class); - - final JsonAdapter adapter = - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - featuresType, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.FeatureListAdapter); - assertNull( - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - Types.newParameterizedType(List.class, String.class), emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - featuresType, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void featureListAdapterIsReadOnly() { - final UniversalFlagConfigParser.FeatureListAdapter adapter = - new UniversalFlagConfigParser.FeatureListAdapter(); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyList())); - } - - @Test - void allowsNullFlagMap() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flags.json")); - - assertNotNull(config); - assertNull(config.flags); - } - - @Test - void skipsNullFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flag.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("null-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void flagMapAdapterIsReadOnly() { - final UniversalFlagConfigParser.FlagMapAdapter adapter = - new UniversalFlagConfigParser.FlagMapAdapter(moshi().adapter(Flag.class)); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyMap())); - } - - @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/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json deleted file mode 100644 index 26d0a90da2a..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "dated-flag": { - "key": "dated-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "dated-allocation", - "rules": [], - "startAt": "2023-01-01T01:00:00.123456+01:00", - "endAt": "2023-01-02T00:00:00.987654Z", - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json deleted file mode 100644 index 51b021e5de7..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": {} -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json deleted file mode 100644 index 93314b69e7d..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "malformed-flag": { - "key": "malformed-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "on": { - "key": "on", - "value": "on" - } - }, - "allocations": "this-is-not-a-list" - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json deleted file mode 100644 index a4f3a6c2502..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "null-flag": null, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json deleted file mode 100644 index a083526e553..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": null -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json deleted file mode 100644 index 6b7dc926854..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": "${serialId}", - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json deleted file mode 100644 index aa791903f66..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "malformed-flag": { - "key": "malformed-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "on": { - "key": "on", - "value": "on" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": "${serialId}", - "variationKey": "on", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": 7, - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json deleted file mode 100644 index d8ff27d02a0..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": 7, - "features": "${features}", - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json deleted file mode 100644 index a380463b1d5..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "operator-grease-flag": { - "key": "operator-grease-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "trap": { - "key": "trap", - "value": "trap" - } - }, - "allocations": [ - { - "key": "grease-allocation", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "not-a-real-operator", - "value": "anything" - } - ] - } - ], - "splits": [ - { - "variationKey": "trap", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json deleted file mode 100644 index fe23d4a46b1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "segments": { - "new-schema-key": { - "ignored": true - } - }, - "flags": {} -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 86a6b18b058..a4e2b8ab815 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -164,7 +164,6 @@ include(":dd-java-agent:agent-aiguard") // Feature Flagging include( ":products:feature-flagging:feature-flagging-agent", - ":products:feature-flagging:feature-flagging-api", ":products:feature-flagging:feature-flagging-bootstrap", ":products:feature-flagging:feature-flagging-config", ":products:feature-flagging:feature-flagging-lib" @@ -345,6 +344,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", From a4c72e3f87c305db65a38258b53b5c4e5d0c64b1 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 07:28:42 +0200 Subject: [PATCH 20/22] refactor(ffe): Remove the feature-flagging-config module Move the Feature Flags configuration keys to dd-trace-api next to the other products. The module has no remaining use since the configuration resolution moved to InstrumenterConfig. Co-Authored-By: Claude Opus 5.5 --- dd-java-agent/agent-bootstrap/build.gradle | 1 - .../java/datadog/trace/bootstrap/Agent.java | 2 +- .../dd-openfeature-connector/build.gradle | 1 - .../ConfigurationResolutionParityTest.java | 6 +- .../api}/config/FeatureFlaggingConfig.java | 20 ++--- gradle/dependencies.gradle | 1 - internal-api/build.gradle.kts | 1 - .../main/java/datadog/trace/api/Config.java | 6 +- .../datadog/trace/api/InstrumenterConfig.java | 6 +- .../java/datadog/trace/api/ConfigTest.java | 6 +- ...InstrumenterConfigFeatureFlaggingTest.java | 6 +- .../feature-flagging-agent/build.gradle.kts | 2 - .../FeatureFlaggingSystemTest.java | 4 +- .../feature-flagging-config/build.gradle.kts | 10 --- .../feature-flagging-config/gradle.lockfile | 75 ------------------- .../feature-flagging-lib/build.gradle.kts | 2 - .../featureflag/FeatureFlagsBackend.java | 4 +- settings.gradle.kts | 1 - 18 files changed, 30 insertions(+), 124 deletions(-) rename {products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag => dd-trace-api/src/main/java/datadog/trace/api}/config/FeatureFlaggingConfig.java (58%) delete mode 100644 products/feature-flagging/feature-flagging-config/build.gradle.kts delete mode 100644 products/feature-flagging/feature-flagging-config/gradle.lockfile diff --git a/dd-java-agent/agent-bootstrap/build.gradle b/dd-java-agent/agent-bootstrap/build.gradle index 3895cb3cbd9..76b833026f2 100644 --- a/dd-java-agent/agent-bootstrap/build.gradle +++ b/dd-java-agent/agent-bootstrap/build.gradle @@ -23,7 +23,6 @@ dependencies { api project(':dd-java-agent:agent-debugger:debugger-bootstrap') api project(':components:environment') api project(':components:json') - api project(':products:feature-flagging:feature-flagging-config') api project(':products:metrics:metrics-agent') api libs.instrument.java api libs.slf4j 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 d9888f23df4..8ca910dde7c 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 @@ -35,6 +35,7 @@ import datadog.trace.api.config.CrashTrackingConfig; import datadog.trace.api.config.CwsConfig; import datadog.trace.api.config.DebuggerConfig; +import datadog.trace.api.config.FeatureFlaggingConfig; import datadog.trace.api.config.GeneralConfig; import datadog.trace.api.config.IastConfig; import datadog.trace.api.config.JmxFetchConfig; @@ -44,7 +45,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; 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 index 9f1de1eb44a..5a8f8b3fd41 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle @@ -26,7 +26,6 @@ dependencies { testImplementation project(':dd-openfeature') testImplementation project(':products:feature-flagging:feature-flagging-bootstrap') - testImplementation project(':products:feature-flagging:feature-flagging-config') testImplementation libs.bundles.mockito } 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 index 9285329db50..79d03ec4e08 100644 --- 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 @@ -1,8 +1,8 @@ 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.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; diff --git a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java similarity index 58% rename from products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java rename to dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java index 785412e787d..50296ef995b 100644 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java @@ -1,21 +1,21 @@ -package datadog.trace.api.featureflag.config; +package datadog.trace.api.config; -/** - * The Feature Flags settings the agent needs: the product switch and the configuration source. The - * {@code dd-openfeature} SDK resolves them with the same semantics, see {@code - * ConfigurationResolutionParityTest} in the {@code dd-openfeature-connector} instrumentation. - */ -public class FeatureFlaggingConfig { - - public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; - public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; +/** Constant with names of configuration options for Feature Flags. */ +public final class FeatureFlaggingConfig { public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; + + /** Use {@link #FEATURE_FLAGS_ENABLED} and {@link #FEATURE_FLAGS_CONFIGURATION_SOURCE}. */ + @Deprecated public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; + + public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; + private FeatureFlaggingConfig() {} } diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index f781cbc134a..606459f9d04 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -24,7 +24,6 @@ final class CachedData { filter.exclude(filter.project(':components:environment')) filter.exclude(filter.project(':components:json')) filter.exclude(filter.project(':products:feature-flagging:feature-flagging-bootstrap')) - filter.exclude(filter.project(':products:feature-flagging:feature-flagging-config')) filter.exclude(filter.project(':products:metrics:metrics-agent')) filter.exclude(filter.project(':products:metrics:metrics-api')) filter.exclude(filter.project(':products:metrics:metrics-lib')) diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index a9d82cebc72..db654f09069 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -276,7 +276,6 @@ dependencies { api(project(":components:context")) api(project(":components:environment")) api(project(":components:json")) - implementation(project(":products:feature-flagging:feature-flagging-config")) api(project(":utils:config-utils")) api(project(":utils:time-utils")) 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 2085e1a0043..cac7bc7d923 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -373,6 +373,9 @@ import static datadog.trace.api.config.DebuggerConfig.THIRD_PARTY_EXCLUDES; import static datadog.trace.api.config.DebuggerConfig.THIRD_PARTY_INCLUDES; import static datadog.trace.api.config.DebuggerConfig.THIRD_PARTY_SHADING_IDENTIFIERS; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.config.GeneralConfig.AGENTLESS_LOG_SUBMISSION_LEVEL; import static datadog.trace.api.config.GeneralConfig.AGENTLESS_LOG_SUBMISSION_QUEUE_SIZE; import static datadog.trace.api.config.GeneralConfig.AGENTLESS_LOG_SUBMISSION_URL; @@ -742,9 +745,6 @@ import static datadog.trace.api.config.TracerConfig.WRITER_BAGGAGE_INJECT; import static datadog.trace.api.config.TracerConfig.WRITER_LINKS_INJECT; 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_ENABLED; import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY; import static datadog.trace.bootstrap.instrumentation.api.WriterConstants.MULTI_WRITER_TYPE; import static datadog.trace.bootstrap.instrumentation.api.WriterConstants.OTLP_WRITER_TYPE; 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 3c92d01665b..64a3e23db76 100644 --- a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java @@ -37,6 +37,9 @@ import static datadog.trace.api.config.AppSecConfig.APPSEC_ENABLED; import static datadog.trace.api.config.AppSecConfig.APPSEC_RASP_ENABLED; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.config.GeneralConfig.AGENTLESS_LOG_SUBMISSION_ENABLED; import static datadog.trace.api.config.GeneralConfig.APP_LOGS_COLLECTION_ENABLED; import static datadog.trace.api.config.GeneralConfig.DATA_JOBS_ENABLED; @@ -102,9 +105,6 @@ 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.util.CollectionUtils.tryMakeImmutableList; import static datadog.trace.util.CollectionUtils.tryMakeImmutableSet; import static java.lang.Boolean.FALSE; diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java index a861dfeffac..a3c21efe461 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java @@ -42,6 +42,9 @@ import static datadog.trace.api.config.DebuggerConfig.DYNAMIC_INSTRUMENTATION_UPLOAD_TIMEOUT; import static datadog.trace.api.config.DebuggerConfig.DYNAMIC_INSTRUMENTATION_VERIFY_BYTECODE; import static datadog.trace.api.config.DebuggerConfig.EXCEPTION_REPLAY_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.config.GeneralConfig.API_KEY; import static datadog.trace.api.config.GeneralConfig.API_KEY_FILE; import static datadog.trace.api.config.GeneralConfig.CONFIGURATION_FILE; @@ -165,9 +168,6 @@ import static datadog.trace.api.config.TracerConfig.TRACE_X_DATADOG_TAGS_MAX_LENGTH; import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; import static datadog.trace.api.env.FixedCapturedEnvironment.useFixedEnv; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.bootstrap.config.provider.ConfigProvider.withPropertiesOverride; import static java.util.Arrays.asList; import static java.util.Collections.emptyList; diff --git a/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java b/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java index 5a10341d0dc..5e5af37b723 100644 --- a/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java +++ b/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java @@ -1,8 +1,8 @@ package datadog.trace.api; -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.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static org.junit.jupiter.api.Assertions.assertEquals; import datadog.trace.bootstrap.config.provider.ConfigProvider; diff --git a/products/feature-flagging/feature-flagging-agent/build.gradle.kts b/products/feature-flagging/feature-flagging-agent/build.gradle.kts index b15250c5c0b..8962b2f5a35 100644 --- a/products/feature-flagging/feature-flagging-agent/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-agent/build.gradle.kts @@ -14,11 +14,9 @@ dependencies { api(libs.slf4j) api(project(":products:feature-flagging:feature-flagging-lib")) api(project(":internal-api")) - compileOnly(project(":products:feature-flagging:feature-flagging-config")) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) - testImplementation(project(":products:feature-flagging:feature-flagging-config")) testImplementation(project(":utils:test-utils")) testRuntimeOnly(project(":dd-trace-core")) } 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 5e6cbfb5d48..0935d1914cb 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 @@ -1,8 +1,8 @@ package com.datadog.featureflag; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; 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_ENABLED; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; diff --git a/products/feature-flagging/feature-flagging-config/build.gradle.kts b/products/feature-flagging/feature-flagging-config/build.gradle.kts deleted file mode 100644 index c7a1f989611..00000000000 --- a/products/feature-flagging/feature-flagging-config/build.gradle.kts +++ /dev/null @@ -1,10 +0,0 @@ -plugins { - `java-library` - id("dd-trace-java.module.internal-api") -} - -description = "Feature flagging configuration keys and source resolution" - -dependencies { - testImplementation(libs.bundles.junit5) -} diff --git a/products/feature-flagging/feature-flagging-config/gradle.lockfile b/products/feature-flagging/feature-flagging-config/gradle.lockfile deleted file mode 100644 index adf89247afa..00000000000 --- a/products/feature-flagging/feature-flagging-config/gradle.lockfile +++ /dev/null @@ -1,75 +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-config:dependencies --write-locks -ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath -com.github.javaparser:javaparser-core:3.25.6=codenarc -com.github.spotbugs:spotbugs-annotations:4.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 -io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath -jaxen:jaxen:2.0.6=spotbugs -net.bytebuddy:byte-buddy-agent:1.12.8=testRuntimeClasspath -net.bytebuddy:byte-buddy:1.12.8=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 -org.codehaus.groovy:groovy-ant:3.0.23=codenarc -org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc -org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc -org.codehaus.groovy:groovy-json:3.0.23=codenarc -org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath -org.codehaus.groovy:groovy-templates:3.0.23=codenarc -org.codehaus.groovy:groovy-xml:3.0.23=codenarc -org.codehaus.groovy:groovy:3.0.23=codenarc -org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath -org.codenarc:CodeNarc:3.7.0=codenarc -org.dom4j:dom4j:2.2.0=spotbugs -org.gmetrics:GMetrics:2.1.0=codenarc -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt -org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt -org.jacoco:org.jacoco.core:0.8.15=jacocoAnt -org.jacoco:org.jacoco.report:0.8.15=jacocoAnt -org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath -org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-core:4.4.0=testRuntimeClasspath -org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.ow2.asm:asm-analysis:9.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:1.7.32=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.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,runtimeClasspath,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 5539b495e70..4a355fa19be 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -11,14 +11,12 @@ dependencies { api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) - compileOnly(project(":products:feature-flagging:feature-flagging-config")) // Platform JSON writer for the ffe_* tag values. compileOnly(project(":components:json")) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) - testImplementation(project(":products:feature-flagging:feature-flagging-config")) testImplementation(project(":utils:test-utils")) testImplementation(project(":dd-java-agent:testing")) } 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 index fdb03ee214e..9aa00200620 100644 --- 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 @@ -1,7 +1,7 @@ package com.datadog.featureflag; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; import datadog.communication.BackendApiFactory; import datadog.communication.ddagent.SharedCommunicationObjects; diff --git a/settings.gradle.kts b/settings.gradle.kts index a4e2b8ab815..8ac120a8520 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -165,7 +165,6 @@ include(":dd-java-agent:agent-aiguard") include( ":products:feature-flagging:feature-flagging-agent", ":products:feature-flagging:feature-flagging-bootstrap", - ":products:feature-flagging:feature-flagging-config", ":products:feature-flagging:feature-flagging-lib" ) From 1a6a1d7087b8731f6285a6e5c94234723f020d30 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 07:58:18 +0200 Subject: [PATCH 21/22] refactor(ffe): Expose the Feature Flags configuration source as an enum Return the configuration source enum from Config now that the previous feature flagging code is gone, and drop the source value constants. Co-Authored-By: Claude Opus 5.5 --- .../ConfigurationResolutionParityTest.java | 42 ++++++++++--------- .../api/config/FeatureFlaggingConfig.java | 4 -- .../main/java/datadog/trace/api/Config.java | 5 +-- .../java/datadog/trace/api/ConfigTest.java | 39 ++++++++--------- .../featureflag/FeatureFlagsBackend.java | 9 ++-- .../featureflag/FeatureFlagsBackendTest.java | 18 ++++---- 6 files changed, 58 insertions(+), 59 deletions(-) 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 index 79d03ec4e08..1fbd430dfcd 100644 --- 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 @@ -3,6 +3,7 @@ import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; +import static java.util.Locale.ROOT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -25,25 +26,26 @@ @ExtendWith(WithConfigExtension.class) 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 | " + "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 case insensitive | | Remote_Config | | remote_config", + "padded source is unsupported | | ' agentless ' | | ", + "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) { @@ -61,7 +63,7 @@ private static String agentSource(final Map settings) { settings.forEach(WithConfigExtension::injectSysConfig); final InstrumenterConfig config = InstrumenterConfig.get(); return config.isFeatureFlaggingEnabled() - ? config.getFeatureFlaggingConfigurationSource() + ? config.getFeatureFlaggingConfigurationSource().name().toLowerCase(ROOT) : null; } diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java index 50296ef995b..9a232111344 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java @@ -13,9 +13,5 @@ public final class FeatureFlaggingConfig { public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; - public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; - - public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; - private FeatureFlaggingConfig() {} } 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 cac7bc7d923..5195c519238 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -4886,9 +4886,8 @@ public boolean isFeatureFlaggingProviderEnabled() { return instrumenterConfig.isFeatureFlaggingEnabled(); } - public String getFeatureFlaggingConfigurationSource() { - final FeatureFlaggingSource source = instrumenterConfig.getFeatureFlaggingConfigurationSource(); - return source == FeatureFlaggingSource.UNSET ? null : source.name().toLowerCase(Locale.ROOT); + public FeatureFlaggingSource getFeatureFlaggingConfigurationSource() { + return instrumenterConfig.getFeatureFlaggingConfigurationSource(); } public boolean isDynamicInstrumentationEnabled() { diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java index a3c21efe461..53fdf018a51 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java @@ -3650,15 +3650,16 @@ void traceResourceRenamingAlwaysSimplifiedEndpointWithValue(String value, boolea @TableTest({ "scenario | value | expected ", - "not set | | agentless ", - "empty | '' | agentless ", - "blank | ' ' | agentless ", - "case insensitive | ReMoTe_ConFiG | remote_config", - "padded | ' remote_config ' | invalid ", - "unknown source | not-a-real-source | invalid ", - "unset is not a value | unset | invalid " + "not set | | AGENTLESS ", + "empty | '' | AGENTLESS ", + "blank | ' ' | AGENTLESS ", + "case insensitive | ReMoTe_ConFiG | REMOTE_CONFIG", + "padded | ' remote_config ' | INVALID ", + "unknown source | not-a-real-source | INVALID ", + "unset is not a value | unset | INVALID " }) - void featureFlagConfigurationSourceParsesValue(String value, String expected) throws Exception { + void featureFlagConfigurationSourceParsesValue(String value, FeatureFlaggingSource expected) + throws Exception { Properties properties = new Properties(); if (value != null) { properties.setProperty(FEATURE_FLAGS_CONFIGURATION_SOURCE, value); @@ -3671,23 +3672,23 @@ void featureFlagConfigurationSourceParsesValue(String value, String expected) th @TableTest({ "scenario | providerEnabled | source | legacyProviderEnabled | expectedEnabled | expectedSource", - "nothing set | | | | true | agentless ", - "provider enabled | true | | | true | agentless ", - "legacy enabled | | | true | true | remote_config ", - "legacy disabled | | | false | false | ", - "source wins over legacy enabled | | agentless | true | true | agentless ", - "source wins over legacy disabled | | remote_config | false | true | remote_config ", - "provider disabled wins over legacy | false | agentless | true | false | agentless ", - "legacy disabled wins over provider | true | | false | false | ", - "unknown source | | not-a-source | | false | invalid ", - "offline source | | offline | true | false | invalid " + "nothing set | | | | true | AGENTLESS ", + "provider enabled | true | | | true | AGENTLESS ", + "legacy enabled | | | true | true | REMOTE_CONFIG ", + "legacy disabled | | | false | false | UNSET ", + "source wins over legacy enabled | | agentless | true | true | AGENTLESS ", + "source wins over legacy disabled | | remote_config | false | true | REMOTE_CONFIG ", + "provider disabled wins over legacy | false | agentless | true | false | AGENTLESS ", + "legacy disabled wins over provider | true | | false | false | UNSET ", + "unknown source | | not-a-source | | false | INVALID ", + "offline source | | offline | true | false | INVALID " }) void featureFlagConfigurationAppliesMigrationPrecedence( Boolean providerEnabled, String source, Boolean legacyProviderEnabled, boolean expectedEnabled, - String expectedSource) + FeatureFlaggingSource expectedSource) throws Exception { Properties properties = new Properties(); if (providerEnabled != null) { 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 index 9aa00200620..c8fc97bf8b3 100644 --- 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 @@ -1,7 +1,7 @@ package com.datadog.featureflag; -import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import static datadog.trace.api.FeatureFlaggingSource.AGENTLESS; +import static datadog.trace.api.FeatureFlaggingSource.REMOTE_CONFIG; import datadog.communication.BackendApiFactory; import datadog.communication.ddagent.SharedCommunicationObjects; @@ -34,7 +34,7 @@ public FeatureFlagsBackend(final SharedCommunicationObjects sco, final Config co config, new EventProxy( new BackendApiFactory(config, sco), - CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) + config.getFeatureFlaggingConfigurationSource() == AGENTLESS && config.getApiKey() != null), SpanEnrichmentWriter.getInstance(), CoreMetricCollector.getInstance()); @@ -55,8 +55,7 @@ public FeatureFlagsBackend(final SharedCommunicationObjects sco, final Config co /** Registers the Remote Configuration product early when it is the configuration source. */ public void start() { - if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals( - this.config.getFeatureFlaggingConfigurationSource()) + if (this.config.getFeatureFlaggingConfigurationSource() == REMOTE_CONFIG && isRemoteConfigAvailable()) { remoteConfig().start(); } 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 index c7a101ef2ff..50331965f96 100644 --- 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 @@ -17,6 +17,7 @@ import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.Product; import datadog.trace.api.Config; +import datadog.trace.api.FeatureFlaggingSource; import datadog.trace.api.telemetry.CoreMetricCollector; import datadog.trace.bootstrap.config.provider.ConfigProvider; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; @@ -65,12 +66,12 @@ void resolvesSettingsWithAgentDefaultsFromTheAgentConfiguration() { @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 " + "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 FeatureFlaggingSource 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); @@ -83,11 +84,12 @@ void startRegistersRemoteConfigOnlyWhenItIsTheSource( @TableTest({ "Scenario | Source | API key", - "agentless with API key | agentless | key ", - "agentless without API key | agentless | ", - "remote config | remote_config | 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) { + void createsTheBackendFromTheAgentConfiguration( + final FeatureFlaggingSource source, final String apiKey) { when(this.config.getFeatureFlaggingConfigurationSource()).thenReturn(source); when(this.config.getApiKey()).thenReturn(apiKey); From ced9ebb1e3aa3a8ff767c590f7780181b5834bdc Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 16:09:56 +0200 Subject: [PATCH 22/22] test(ffe): Check padded settings resolve alike in the agent and the SDK Cover the padded boolean settings in the configuration resolution parity test, and let missing settings fall back to the injected agent settings. Co-Authored-By: Claude Opus 5.5 --- .../ConfigurationResolutionParityTest.java | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) 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 index 1fbd430dfcd..ea366116937 100644 --- 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 @@ -26,26 +26,29 @@ @ExtendWith(WithConfigExtension.class) 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 case insensitive | | Remote_Config | | remote_config", - "padded source is unsupported | | ' agentless ' | | ", - "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 | " + "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 case insensitive | | Remote_Config | | remote_config", + "padded source is unsupported | | ' agentless ' | | ", + "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 | ", + "padded enabled reads as false | ' true ' | | | ", + "padded number reads as false | ' 1 ' | | | ", + "padded legacy reads as false | | | ' true ' | " }) void agentAndSdkResolveTheSameConfiguration( final String enabled, final String source, final String legacy, final String expected) { @@ -68,13 +71,13 @@ private static String agentSource(final Map settings) { } /** - * 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. + * Resolves the source like the SDK. Missing settings fall back to the system properties injected + * for the agent, which hold the same values. */ private static String sdkSource(final Map settings) { final Connector connector = mock(Connector.class); when(connector.setting(anyString())) - .thenAnswer(invocation -> settings.getOrDefault(invocation.getArgument(0), "")); + .thenAnswer(invocation -> settings.get(invocation.getArgument(0))); return Settings.load(connector).configurationSource(); }