diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index 88531ceaeed..98620a65b19 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -1,5 +1,7 @@ import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension import groovy.lang.Closure +import org.gradle.api.tasks.SourceSetContainer +import java.util.zip.ZipFile plugins { `java-library` @@ -42,13 +44,19 @@ java { dependencies { api("dev.openfeature:sdk:1.20.1") + implementation(libs.moshi) + implementation(libs.slf4j) compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap")) compileOnly(project(":products:feature-flagging:feature-flagging-config")) + compileOnly(project(":products:feature-flagging:feature-flagging-core")) + compileOnly(project(":products:feature-flagging:feature-flagging-http")) compileOnly(project(":utils:config-utils")) compileOnly("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) + testImplementation(project(":products:feature-flagging:feature-flagging-core")) + testImplementation(project(":products:feature-flagging:feature-flagging-http")) testImplementation(project(":utils:config-utils")) testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(libs.bundles.junit5) @@ -78,8 +86,57 @@ tasks.withType().configureEach { javadocTool = javaToolchains.javadocToolFor(java.toolchain) } +val coreProject = project(":products:feature-flagging:feature-flagging-core") +val httpProject = project(":products:feature-flagging:feature-flagging-http") +val bootstrapProject = project(":products:feature-flagging:feature-flagging-bootstrap") + +tasks.named("jar") { + from(coreProject.extensions.getByType().named("main").map { it.output }) + from(httpProject.extensions.getByType().named("main").map { it.output }) + from(bootstrapProject.extensions.getByType().named("main").map { it.output }) { + include("datadog/trace/api/featureflag/ufc/v1/**") + } +} + +tasks.withType().configureEach { + dependsOn(tasks.named("jar")) + systemProperty( + "dd.openfeature.test.jar", + tasks.named("jar").get().archiveFile.get().asFile.absolutePath + ) +} + // The dd-openfeature provider jar is not produced by the CI `build` job, so there is no reference // artifact to compare against. Disable the release jar comparison gate registered by publish.gradle. tasks.named("compareToReferenceJar") { enabled = false } + +tasks.register("verifyDdOpenfeatureArtifact") { + dependsOn(tasks.named("jar"), tasks.named("generatePomFileForMavenPublication")) + doLast { + val providerJar = tasks.named("jar").get().archiveFile.get().asFile + val requiredEntries = setOf( + "datadog/openfeature/internal/core/ConfigurationStore.class", + "datadog/openfeature/internal/core/FlagEvaluator.class", + "datadog/openfeature/internal/http/CdnConfigurationSource.class", + "datadog/openfeature/internal/http/HttpConfigurationOptions.class", + "datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.class" + ) + ZipFile(providerJar).use { zip -> + val entryNames = zip.entries().asSequence().map { it.name }.toSet() + val missing = requiredEntries - entryNames + check(missing.isEmpty()) { + "dd-openfeature is missing embedded standalone classes: $missing" + } + } + + val pom = layout.buildDirectory.file("publications/maven/pom-default.xml").get().asFile + check(pom.isFile) { "Generated dd-openfeature Maven POM is missing" } + val pomText = pom.readText() + check(pomText.contains("sdk")) + check(pomText.contains("moshi")) + check(!pomText.contains("feature-flagging-core")) + check(!pomText.contains("feature-flagging-http")) + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index 6d55c54a5b3..979b9702153 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -1,64 +1,37 @@ package datadog.trace.api.openfeature; -import static java.util.Arrays.asList; - import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.Rule; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.AbstractMap; -import java.util.Date; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; -import java.util.Objects; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; +/** Agent-backed entrypoint over the shared Feature Flagging evaluator. */ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { - private static final Set> SUPPORTED_RESOLUTION_TYPES = - new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); - - // Evaluation-metadata keys consumed by the span-enrichment capture hook (see - // SpanEnrichmentHook). Emitted only when the span-enrichment gate is on. static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; static final String METADATA_DO_LOG = "__dd_do_log"; - // Read once: when off, the __dd_* span-enrichment metadata is not attached to evaluations, so an - // enabled provider pays nothing extra unless span enrichment is also enabled. The gate does not - // change at runtime, and this class is loaded lazily (well after startup) so config is ready. private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); private final Runnable configCallback; private final AtomicReference configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); + private final OpenFeatureEvaluationAdapter evaluator = + new OpenFeatureEvaluationAdapter(DDEvaluator::dispatchExposure, SPAN_ENRICHMENT_ENABLED); public DDEvaluator(final Runnable configCallback) { this.configCallback = configCallback; @@ -99,412 +72,18 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { - try { - final ServerConfiguration config = configuration.get(); - if (config == null) { - return error(defaultValue, ErrorCode.PROVIDER_NOT_READY); - } - - if (context == null) { - return error(defaultValue, ErrorCode.INVALID_CONTEXT); - } - - final Flag flag = config.flags.get(key); - if (flag == null) { - return error(defaultValue, ErrorCode.FLAG_NOT_FOUND); - } - - if (!flag.enabled) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DISABLED.name()) - .build(); - } - - if (flag.allocations == null) { - return error(defaultValue, ErrorCode.GENERAL, "Missing allocations for flag " + key); - } - - final Date now = new Date(); - final String targetingKey = context.getTargetingKey(); - - for (final Allocation allocation : flag.allocations) { - if (!isAllocationActive(allocation, now)) { - continue; - } - - if (!isEmpty(allocation.rules)) { - if (!evaluateRules(allocation.rules, context)) { - continue; - } - } - - if (!isEmpty(allocation.splits)) { - for (final Split split : allocation.splits) { - if (isEmpty(split.shards)) { - return resolveVariant( - target, key, defaultValue, flag, split.variationKey, allocation, split, context); - } else { - if (targetingKey == null) { - return error(defaultValue, ErrorCode.TARGETING_KEY_MISSING); - } - // To match a split, subject must match ALL underlying shards - boolean allShardsMatch = true; - for (final Shard shard : split.shards) { - if (!matchesShard(shard, targetingKey)) { - allShardsMatch = false; - break; - } - } - if (allShardsMatch) { - return resolveVariant( - target, - key, - defaultValue, - flag, - split.variationKey, - allocation, - split, - context); - } - } - } - } - } - - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.name()) - .build(); - } catch (final PatternSyntaxException e) { - return error(defaultValue, ErrorCode.PARSE_ERROR, e); - } catch (final NumberFormatException e) { - return error(defaultValue, ErrorCode.TYPE_MISMATCH, e); - } catch (final Exception e) { - return error(defaultValue, ErrorCode.GENERAL, e); - } - } - - private static ProviderEvaluation error(final T defaultValue, final ErrorCode code) { - return error(defaultValue, code, (String) null); - } - - private static ProviderEvaluation error( - final T defaultValue, final ErrorCode code, final Throwable cause) { - return error(defaultValue, code, cause == null ? null : cause.getMessage()); - } - - private static ProviderEvaluation error( - final T defaultValue, final ErrorCode code, final String errorMessage) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(code) - .errorMessage(errorMessage) - .build(); - } - - private static boolean isEmpty(final List list) { - return list == null || list.isEmpty(); - } - - private static boolean isAllocationActive(final Allocation allocation, final Date now) { - final Date startDate = allocation.startAt; - if (startDate != null && now.before(startDate)) { - return false; - } - - final Date endDate = allocation.endAt; - if (endDate != null && now.after(endDate)) { - return false; - } - - return true; - } - - private static boolean evaluateRules(final List rules, final EvaluationContext context) { - for (final Rule rule : rules) { - if (isEmpty(rule.conditions)) { - continue; - } - - boolean allConditionsMatch = true; - for (final ConditionConfiguration condition : rule.conditions) { - if (!evaluateCondition(condition, context)) { - allConditionsMatch = false; - break; - } - } - - if (allConditionsMatch) { - return true; - } - } - return false; - } - - private static boolean evaluateCondition( - final ConditionConfiguration condition, final EvaluationContext context) { - if (condition.operator == ConditionOperator.IS_NULL) { - final Object value = resolveAttribute(condition.attribute, context); - boolean isNull = value == null; - // condition.value determines if we're checking for null (true) or not null (false) - boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true; - return isNull == expectedNull; - } - - final Object attributeValue = resolveAttribute(condition.attribute, context); - if (attributeValue == null) { - return false; - } - - switch (condition.operator) { - case MATCHES: - return matchesRegex(attributeValue, condition.value); - case NOT_MATCHES: - return !matchesRegex(attributeValue, condition.value); - case ONE_OF: - return isOneOf(attributeValue, condition.value); - case NOT_ONE_OF: - return !isOneOf(attributeValue, condition.value); - case GTE: - return compareNumber(attributeValue, condition.value, (a, b) -> a >= b); - case GT: - return compareNumber(attributeValue, condition.value, (a, b) -> a > b); - case LTE: - return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); - case LT: - return compareNumber(attributeValue, condition.value, (a, b) -> a < b); - default: - return false; - } - } - - private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { - // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), - // which maps it to ErrorCode.PARSE_ERROR. - final Pattern pattern = Pattern.compile(String.valueOf(conditionValue)); - return pattern.matcher(String.valueOf(attributeValue)).find(); - } - - private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { - if (!(conditionValue instanceof Iterable)) { - return false; - } - for (final Object value : (Iterable) conditionValue) { - if (valuesEqual(attributeValue, value)) { - return true; - } - } - return false; + return evaluator.evaluate(configuration.get(), target, key, defaultValue, context); } - 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); - } - - private static boolean matchesShard(final Shard shard, final String targetingKey) { - final int assignedShard = getShard(shard.salt, targetingKey, shard.totalShards); - for (final ShardRange range : shard.ranges) { - if (assignedShard >= range.start && assignedShard < range.end) { - return true; - } - } - return false; - } - - private static int getShard(final String salt, final String targetingKey, final int totalShards) { - final String hashKey = salt + "-" + targetingKey; - final String md5Hash = getMD5Hash(hashKey); - final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length())); - final long intFromHash = Long.parseLong(first8Chars, 16); - return (int) (intFromHash % totalShards); - } - - private static String getMD5Hash(final String input) { - try { - final MessageDigest md = MessageDigest.getInstance("MD5"); - final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); - final StringBuilder hexString = new StringBuilder(); - for (byte b : hashBytes) { - final String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) { - hexString.append('0'); - } - hexString.append(hex); - } - return hexString.toString(); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("MD5 algorithm not available", e); - } - } - - 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 Variant variant = flag.variations.get(variationKey); - if (variant == null) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("Variant not found for: " + variationKey) - .build(); - } - - if (!isTypeCompatible(target, flag.variationType)) { - return error( - defaultValue, - ErrorCode.TYPE_MISMATCH, - "Requested type " - + target.getSimpleName() - + " does not match flag variationType " - + flag.variationType.name()); - } - - final 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()); - } - - final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = - ImmutableMetadata.builder() - .addString("flagKey", flag.key) - .addString("variationType", flag.variationType.name()) - .addString("allocationKey", allocation.key); - // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — - // only when span enrichment is on, so a provider without enrichment pays nothing extra. - // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always - // present (when enrichment is on) so the span-enrichment hook can decide whether to record the - // subject. - if (SPAN_ENRICHMENT_ENABLED) { - if (split.serialId != null) { - metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); - } - 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() - : !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); - } - 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)); + return OpenFeatureEvaluationAdapter.mapValue(target, value); } - private static void dispatchExposure( - final String flag, final ProviderEvaluation evaluation, final EvaluationContext context) { - final String allocationKey = allocationKey(evaluation); - final String variantKey = evaluation.getVariant(); - if (allocationKey == null || variantKey == null) { - return; - } + private static void dispatchExposure( + final String flag, + final String allocationKey, + final String variantKey, + final EvaluationContext context) { final ExposureEvent event = new ExposureEvent( System.currentTimeMillis(), @@ -512,15 +91,9 @@ private static void dispatchExposure( new datadog.trace.api.featureflag.exposure.Flag(flag), new datadog.trace.api.featureflag.exposure.Variant(variantKey), new Subject(context.getTargetingKey(), flattenContext(context))); - FeatureFlaggingGateway.dispatch(event); } - 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) { final Set keys = context.keySet(); final HashMap result = new HashMap<>(); @@ -554,12 +127,7 @@ static AbstractMap flattenContext(final EvaluationContext contex return result; } - @FunctionalInterface - private interface NumberComparator { - boolean compare(double a, double b); - } - - private static class FlattenEntry { + private static final class FlattenEntry { private final String key; private final Value value; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java new file mode 100644 index 00000000000..180a35d9c0d --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java @@ -0,0 +1,238 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.core.EvaluationResult; +import datadog.openfeature.internal.core.EvaluationResult.Reason; +import datadog.openfeature.internal.core.FlagEvaluator; +import datadog.openfeature.internal.core.FlagEvaluator.ValueKind; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +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; + +/** Maps OpenFeature values to the shared evaluator model. */ +final class OpenFeatureEvaluationAdapter { + + interface ExposureHandler { + void accept( + String flagKey, + String allocationKey, + String variantKey, + EvaluationContext evaluationContext); + } + + private static final ExposureHandler NO_EXPOSURES = + (flagKey, allocationKey, variantKey, evaluationContext) -> {}; + + private final FlagEvaluator evaluator = new FlagEvaluator(); + private final ExposureHandler exposureHandler; + private final boolean spanEnrichmentEnabled; + + OpenFeatureEvaluationAdapter( + final ExposureHandler exposureHandler, final boolean spanEnrichmentEnabled) { + this.exposureHandler = exposureHandler == null ? NO_EXPOSURES : exposureHandler; + this.spanEnrichmentEnabled = spanEnrichmentEnabled; + } + + ProviderEvaluation evaluate( + final ServerConfiguration snapshot, + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + try { + final EvaluationResult result = + evaluator.evaluate( + snapshot, + valueKind(target), + key, + unwrapDefaultValue(defaultValue), + toCoreContext(context)); + return toProviderEvaluation(target, key, defaultValue, context, result); + } catch (final RuntimeException error) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(dev.openfeature.sdk.Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage(error.getMessage()) + .build(); + } + } + + private ProviderEvaluation toProviderEvaluation( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context, + final EvaluationResult result) { + if (result.error != null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(dev.openfeature.sdk.Reason.ERROR.name()) + .errorCode(errorCode(result.error)) + .errorMessage(result.errorMessage) + .build(); + } + + final ImmutableMetadata.ImmutableMetadataBuilder metadata = ImmutableMetadata.builder(); + if (result.flagKey != null) { + metadata.addString("flagKey", result.flagKey); + } + if (result.variationType != null) { + metadata.addString("variationType", result.variationType); + } + if (result.allocationKey != null) { + metadata.addString("allocationKey", result.allocationKey); + } + if (spanEnrichmentEnabled) { + if (result.splitSerialId != null) { + metadata.addInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID, result.splitSerialId); + } + metadata.addBoolean(DDEvaluator.METADATA_DO_LOG, result.doLog); + } + + final T value = + target == Value.class + && (result.reason == Reason.DISABLED || result.reason == Reason.DEFAULT) + ? defaultValue + : mapResultValue(target, result.value); + final ProviderEvaluation evaluation = + ProviderEvaluation.builder() + .value(value) + .reason(result.reason.name()) + .variant(result.variant) + .flagMetadata(metadata.build()) + .build(); + if (result.doLog && context != null && result.allocationKey != null && result.variant != null) { + exposureHandler.accept(key, result.allocationKey, result.variant, context); + } + return evaluation; + } + + static ValueKind valueKind(final Class target) { + if (target == Boolean.class) { + return ValueKind.BOOLEAN; + } + if (target == String.class) { + return ValueKind.STRING; + } + if (target == Integer.class) { + return ValueKind.INTEGER; + } + if (target == Double.class) { + return ValueKind.DOUBLE; + } + if (target == Value.class) { + return ValueKind.OBJECT; + } + throw new IllegalArgumentException("Type not supported: " + target); + } + + private static datadog.openfeature.internal.core.EvaluationContext toCoreContext( + final EvaluationContext context) { + if (context == null) { + return null; + } + return datadog.openfeature.internal.core.EvaluationContext.lazy( + context.getTargetingKey(), + new datadog.openfeature.internal.core.EvaluationContext.AttributeProvider() { + @Override + public boolean contains(final String name) { + return context.keySet().contains(name); + } + + @Override + public Object get(final String name) { + return unwrapValue(context.getValue(name)); + } + }); + } + + private static ErrorCode errorCode(final EvaluationResult.Error error) { + switch (error) { + case PROVIDER_NOT_READY: + return ErrorCode.PROVIDER_NOT_READY; + case INVALID_CONTEXT: + return ErrorCode.INVALID_CONTEXT; + case FLAG_NOT_FOUND: + return ErrorCode.FLAG_NOT_FOUND; + case TARGETING_KEY_MISSING: + return ErrorCode.TARGETING_KEY_MISSING; + case TYPE_MISMATCH: + return ErrorCode.TYPE_MISMATCH; + case PARSE_ERROR: + return ErrorCode.PARSE_ERROR; + default: + return ErrorCode.GENERAL; + } + } + + @SuppressWarnings("unchecked") + private static T mapResultValue(final Class target, final Object value) { + if (target == Value.class) { + return (T) Value.objectToValue(value); + } + return target.cast(value); + } + + @SuppressWarnings("unchecked") + static T mapValue(final Class target, final Object value) { + final Object mapped = FlagEvaluator.mapValue(valueKind(target), value); + return mapped == null + ? null + : target == Value.class ? (T) Value.objectToValue(mapped) : target.cast(mapped); + } + + static Object unwrapDefaultValue(final Object value) { + return value instanceof Value ? unwrapValue((Value) value) : value; + } + + private static Object unwrapValue(final Value value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isStructure()) { + final Map map = new LinkedHashMap<>(); + if (value.asStructure() != null) { + for (final String key : value.asStructure().keySet()) { + map.put(key, unwrapValue(value.asStructure().getValue(key))); + } + } + return map; + } + if (value.isList()) { + final List list = value.asList(); + final List output = new ArrayList<>(list == null ? 0 : list.size()); + if (list != null) { + for (final Value element : list) { + output.add(unwrapValue(element)); + } + } + return output; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isString()) { + return value.asString(); + } + if (value.isNumber()) { + final Double number = value.asDouble(); + if (number != null && number == Math.rint(number) && !Double.isInfinite(number)) { + final Integer integer = value.asInteger(); + if (integer != null) { + return integer; + } + } + return number; + } + final Instant instant = value.asInstant(); + return instant == null ? value.asObject() : instant.toString(); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index 38fa735d4e9..7f9bc2eddd0 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -29,6 +29,8 @@ 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 String AGENT_GATEWAY = + "datadog.trace.api.featureflag.FeatureFlaggingGateway"; private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); private volatile Evaluator evaluator; @@ -129,7 +131,7 @@ public void initialize(final EvaluationContext context) throws Exception { throw e; } catch (final Throwable e) { markInitializationError(); - throw new FatalError("Failed to initialize provider, is the tracer configured?", e); + throw new FatalError("Failed to initialize provider: " + e.getMessage(), e); } } @@ -206,6 +208,9 @@ private Evaluator buildEvaluator() throws Exception { if (evaluator != null) { return evaluator; } + if (!isAgentGatewayAvailable()) { + return new StandaloneDDEvaluator(this::onConfigurationChange); + } final Class evaluatorClass = loadEvaluatorClass(); final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); @@ -279,6 +284,16 @@ protected Class loadEvaluatorClass() throws ClassNotFoundException { return Class.forName(EVALUATOR_IMPL); } + @SuppressForbidden // Class#forName is required because the agent classes are optional + protected boolean isAgentGatewayAvailable() { + try { + Class.forName(AGENT_GATEWAY, false, Provider.class.getClassLoader()); + return true; + } catch (final ClassNotFoundException | LinkageError ignored) { + return false; + } + } + private enum InitializationState { NOT_STARTED, INITIALIZING, diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java new file mode 100644 index 00000000000..411a2c8ebfd --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java @@ -0,0 +1,63 @@ +package datadog.trace.api.openfeature; + +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.concurrent.TimeUnit; + +/** No-agent entrypoint that owns CDN polling and delegates evaluation to the shared core. */ +final class StandaloneDDEvaluator implements Evaluator { + + private final Runnable configCallback; + private final OpenFeatureEvaluationAdapter evaluator = + new OpenFeatureEvaluationAdapter(null, false); + private volatile StandaloneProviderRuntime.Handle runtime; + + StandaloneDDEvaluator(final Runnable configCallback) { + this.configCallback = configCallback; + } + + @Override + public boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { + StandaloneProviderRuntime.Handle current = runtime; + if (current == null) { + synchronized (this) { + current = runtime; + if (current == null) { + current = + StandaloneProviderRuntime.acquire( + StandaloneRuntimeConfiguration.resolve(), ignored -> configCallback.run()); + runtime = current; + } + } + } + return current.awaitConfiguration(timeout, unit) || hasConfiguration(); + } + + @Override + public boolean hasConfiguration() { + final StandaloneProviderRuntime.Handle current = runtime; + return current != null && current.configuration() != null; + } + + @Override + public void shutdown() { + final StandaloneProviderRuntime.Handle current = runtime; + runtime = null; + if (current != null) { + current.close(); + } + } + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + final StandaloneProviderRuntime.Handle current = runtime; + final ServerConfiguration snapshot = current == null ? null : current.configuration(); + return evaluator.evaluate(snapshot, target, key, defaultValue, context); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java new file mode 100644 index 00000000000..21af19e69d7 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java @@ -0,0 +1,118 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.core.ConfigurationSource; +import datadog.openfeature.internal.core.ConfigurationStore; +import datadog.openfeature.internal.http.CdnConfigurationSource; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** One reference-counted CDN runtime per provider classloader. */ +final class StandaloneProviderRuntime { + + private static final Object LOCK = new Object(); + private static SharedRuntime shared; + + private StandaloneProviderRuntime() {} + + static Handle acquire( + final StandaloneRuntimeConfiguration configuration, + final Consumer listener) { + if (configuration.source == StandaloneRuntimeConfiguration.Source.DISABLED) { + throw new IllegalStateException("Datadog OpenFeature provider is disabled by configuration"); + } + if (configuration.source == StandaloneRuntimeConfiguration.Source.REMOTE_CONFIG) { + throw new IllegalStateException( + "The remote_config source requires dd-java-agent.jar. " + + "Use the agentless source when the Java agent is not installed."); + } + synchronized (LOCK) { + if (shared == null) { + shared = new SharedRuntime(configuration); + } else if (!shared.configuration.equals(configuration)) { + throw new IllegalStateException( + "All Datadog OpenFeature providers in one application classloader must use " + + "the same configuration source and options"); + } + shared.references++; + shared.store.addListener(listener); + try { + shared.start(); + } catch (final RuntimeException | Error e) { + shared.store.removeListener(listener); + shared.references--; + if (shared.references == 0) { + shared.close(); + shared = null; + } + throw e; + } + return new Handle(shared, listener); + } + } + + static final class Handle implements AutoCloseable { + private SharedRuntime runtime; + private Consumer listener; + + private Handle(final SharedRuntime runtime, final Consumer listener) { + this.runtime = runtime; + this.listener = listener; + } + + ServerConfiguration configuration() { + return runtime == null ? null : runtime.store.current(); + } + + boolean awaitConfiguration(final long timeout, final TimeUnit unit) + throws InterruptedException { + return runtime != null && runtime.store.awaitConfiguration(timeout, unit); + } + + @Override + public void close() { + synchronized (LOCK) { + if (runtime == null) { + return; + } + runtime.store.removeListener(listener); + runtime.references--; + if (runtime.references == 0) { + runtime.close(); + if (shared == runtime) { + shared = null; + } + } + runtime = null; + listener = null; + } + } + } + + private static final class SharedRuntime { + private final StandaloneRuntimeConfiguration configuration; + private final ConfigurationStore store = new ConfigurationStore(); + private final ConfigurationSource source; + private int references; + private boolean started; + + private SharedRuntime(final StandaloneRuntimeConfiguration configuration) { + this.configuration = configuration; + source = new CdnConfigurationSource(configuration.http, store); + } + + private void start() { + if (started) { + return; + } + started = true; + source.start(); + } + + private void close() { + source.close(); + store.clear(); + started = false; + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java new file mode 100644 index 00000000000..29fac06b82d --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java @@ -0,0 +1,164 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.http.CdnEndpointResolver; +import datadog.openfeature.internal.http.HttpConfigurationOptions; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.net.URI; +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; + +/** Immutable configuration for the provider-owned CDN runtime. */ +final class StandaloneRuntimeConfiguration { + + enum Source { + CDN, + REMOTE_CONFIG, + DISABLED + } + + final Source source; + final HttpConfigurationOptions http; + + private StandaloneRuntimeConfiguration(final Source source, final HttpConfigurationOptions http) { + this.source = source; + this.http = http; + } + + static StandaloneRuntimeConfiguration resolve() { + final Boolean providerEnabled = + booleanSetting("dd.feature.flags.enabled", "DD_FEATURE_FLAGS_ENABLED"); + final String sourceValue = + setting("dd.feature.flags.configuration.source", "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"); + final Boolean legacyProviderEnabled = + booleanSetting( + "dd.experimental.flagging.provider.enabled", + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"); + final Source source = resolveSource(providerEnabled, sourceValue, legacyProviderEnabled); + if (source != Source.CDN) { + return new StandaloneRuntimeConfiguration(source, null); + } + + final String configuredBaseUrl = + setting( + "dd.feature.flags.configuration.source.agentless.base.url", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL"); + final String site = first(setting("dd.site", "DD_SITE"), "datadoghq.com"); + final String environment = setting("dd.env", "DD_ENV"); + final URI endpoint = CdnEndpointResolver.resolve(configuredBaseUrl, site, environment); + final Duration pollInterval = + seconds( + setting( + "dd.feature.flags.configuration.source.agentless.poll.interval.seconds", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS"), + 30); + final Duration requestTimeout = + seconds( + setting( + "dd.feature.flags.configuration.source.agentless.request.timeout.seconds", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS"), + 5); + final String apiKey = setting("dd.api.key", "DD_API_KEY"); + return new StandaloneRuntimeConfiguration( + source, + HttpConfigurationOptions.builder() + .endpoint(endpoint) + .pollInterval(pollInterval) + .requestTimeout(requestTimeout) + .apiKey(apiKey) + .managedEndpoint(configuredBaseUrl == null || configuredBaseUrl.trim().isEmpty()) + .build()); + } + + static Source resolveSource( + final Boolean providerEnabled, + final String sourceValue, + final Boolean legacyProviderEnabled) { + if (Boolean.FALSE.equals(providerEnabled)) { + return Source.DISABLED; + } + if (sourceValue == null || sourceValue.trim().isEmpty()) { + if (legacyProviderEnabled != null) { + return legacyProviderEnabled ? Source.REMOTE_CONFIG : Source.DISABLED; + } + return Source.CDN; + } + final String normalized = sourceValue.trim().toLowerCase(Locale.ROOT); + if ("agentless".equals(normalized)) { + return Source.CDN; + } + if ("remote_config".equals(normalized)) { + return Source.REMOTE_CONFIG; + } + throw new IllegalArgumentException( + "Unsupported Feature Flagging configuration source: " + sourceValue); + } + + private static Duration seconds(final String value, final long defaultValue) { + if (value == null) { + return Duration.ofSeconds(defaultValue); + } + try { + final long seconds = Long.parseLong(value); + return seconds > 0 ? Duration.ofSeconds(seconds) : Duration.ofSeconds(defaultValue); + } catch (final NumberFormatException ignored) { + return Duration.ofSeconds(defaultValue); + } + } + + @SuppressForbidden + private static String setting(final String property, final String environment) { + final String propertyValue = System.getProperty(property); + return propertyValue != null ? propertyValue : System.getenv(environment); + } + + private static Boolean booleanSetting(final String property, final String environment) { + final String value = setting(property, environment); + return value == null ? null : Boolean.valueOf(value); + } + + private static String first(final String... values) { + for (final String value : values) { + if (value != null && !value.isEmpty()) { + return value; + } + } + return null; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof StandaloneRuntimeConfiguration)) { + return false; + } + final StandaloneRuntimeConfiguration that = (StandaloneRuntimeConfiguration) other; + return source == that.source + && Objects.equals( + http == null ? null : http.endpoint, that.http == null ? null : that.http.endpoint) + && Objects.equals( + http == null ? null : http.pollInterval, + that.http == null ? null : that.http.pollInterval) + && Objects.equals( + http == null ? null : http.requestTimeout, + that.http == null ? null : that.http.requestTimeout) + && Objects.equals( + http == null ? null : http.apiKey, that.http == null ? null : that.http.apiKey) + && (http == null + ? that.http == null + : that.http != null && http.managedEndpoint == that.http.managedEndpoint); + } + + @Override + public int hashCode() { + return Objects.hash( + source, + http == null ? null : http.endpoint, + http == null ? null : http.pollInterval, + http == null ? null : http.requestTimeout, + http == null ? null : http.apiKey, + http != null && http.managedEndpoint); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java index 7b6cc01e021..30f6a610d8b 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -5,39 +5,31 @@ import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; import static java.util.concurrent.TimeUnit.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.assertThrows; 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.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.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; -import java.io.IOException; -import java.lang.reflect.Type; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -46,8 +38,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.stream.Collectors; -import java.util.stream.Stream; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -55,16 +46,6 @@ 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()).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); - @Test public void testInitializeSignalsApplicationProviderActivation() throws Exception { final FeatureFlaggingGateway.ActivationListener listener = @@ -212,6 +193,42 @@ public void testNoAllocations() { assertThat(details.getErrorCode(), nullValue()); } + @Test + public void testDispatchesExposureForSuccessfulEvaluation() { + final AtomicReference exposure = new AtomicReference<>(); + final FeatureFlaggingGateway.ExposureListener listener = exposure::set; + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final Split split = new Split(emptyList(), "on", emptyMap(), 7); + final Allocation allocation = + new Allocation("allocation", emptyList(), null, null, singletonList(split), true); + final Flag flag = + new Flag( + "flag", + true, + ValueType.STRING, + singletonMap("on", new Variant("on", "hello")), + singletonList(allocation)); + final Map attributes = new HashMap<>(); + attributes.put("nullable", new Value()); + final EvaluationContext context = new MutableContext("subject", attributes); + FeatureFlaggingGateway.addExposureListener(listener); + try { + evaluator.accept(new ServerConfiguration(null, "SERVER", null, singletonMap("flag", flag))); + + assertThat( + evaluator.evaluate(String.class, "flag", "default", context).getValue(), + equalTo("hello")); + assertThat(exposure.get().flag.key, equalTo("flag")); + assertThat(exposure.get().allocation.key, equalTo("allocation")); + assertThat(exposure.get().variant.key, equalTo("on")); + assertThat(exposure.get().subject.id, equalTo("subject")); + assertThat(exposure.get().subject.attributes, hasEntry("nullable", null)); + } finally { + FeatureFlaggingGateway.removeExposureListener(listener); + evaluator.shutdown(); + } + } + private static Arguments[] flatteningTestCases() { final List arguments = new ArrayList<>(); arguments.add(Arguments.of(emptyMap(), emptyMap())); @@ -244,130 +261,6 @@ public void testFlattening( } } - @Test - public void testCanonicalFixturesArePresent() throws IOException { - assertThat(canonicalTestCases().size(), greaterThan(0)); - } - - @MethodSource("canonicalTestCases") - @ParameterizedTest(name = "{0}") - public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(loadCanonicalConfiguration()); - - final Class targetType = targetType(testCase.variationType); - final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); - final Object expectedValue = mapFixtureValue(targetType, testCase.result.value); - final ProviderEvaluation details = - evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); - - assertThat(details.getValue(), equalTo(expectedValue)); - assertThat(details.getReason(), equalTo(testCase.result.reason)); - if (testCase.result.variant != null) { - assertThat(details.getVariant(), equalTo(testCase.result.variant)); - } - if (testCase.result.errorCode != null) { - assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.result.errorCode))); - } - if (testCase.result.flagMetadata != null - && testCase.result.flagMetadata.get("allocationKey") != null) { - assertThat( - details.getFlagMetadata().getString("allocationKey"), - equalTo(String.valueOf(testCase.result.flagMetadata.get("allocationKey")))); - } - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private static ProviderEvaluation evaluate( - final DDEvaluator evaluator, - final Class targetType, - final String flag, - final Object defaultValue, - final EvaluationContext context) { - return evaluator.evaluate((Class) targetType, flag, defaultValue, context); - } - - private static ServerConfiguration loadCanonicalConfiguration() throws IOException { - return CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); - } - - 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; @@ -378,51 +271,4 @@ private static Map mapOf(final Object... props) { } return result; } - - private static final class FixtureCase { - Map attributes = emptyMap(); - Object defaultValue; - String flag; - FixtureResult result; - String targetingKey; - String variationType; - transient String fileName; - transient int index; - - @Override - public String toString() { - return fileName + "[" + index + "] flag=" + flag; - } - } - - private static final class FixtureResult { - Object value; - String reason; - String errorCode; - String variant; - Map flagMetadata = emptyMap(); - } - - private static final class DateAdapter extends JsonAdapter { - @Override - public Date fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - try { - return Date.from(OffsetDateTime.parse(reader.nextString()).toInstant()); - } catch (final Exception ignored) { - return null; - } - } - - @Override - public void toJson(final JsonWriter writer, final Date value) throws IOException { - if (value == null) { - writer.nullValue(); - return; - } - writer.value(value.toInstant().toString()); - } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java new file mode 100644 index 00000000000..45217a0aff3 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java @@ -0,0 +1,203 @@ +package datadog.trace.api.openfeature; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +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.Environment; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Shard; +import datadog.trace.api.featureflag.ufc.v1.ShardRange; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class OpenFeatureEvaluationAdapterTest { + + @Test + void mapsEvaluationMetadataContextAndExposure() { + final AtomicInteger exposures = new AtomicInteger(); + final OpenFeatureEvaluationAdapter adapter = + new OpenFeatureEvaluationAdapter( + (flagKey, allocationKey, variantKey, context) -> exposures.incrementAndGet(), true); + final Rule rule = + new Rule( + singletonList( + new ConditionConfiguration(ConditionOperator.ONE_OF, "id", singletonList("US")))); + final ProviderEvaluation result = + adapter.evaluate( + configuration(ValueType.STRING, "hello", true, singletonList(rule), emptyList()), + String.class, + "flag", + "default", + new MutableContext("subject").add("id", "US")); + + assertEquals("hello", result.getValue()); + assertEquals("TARGETING_MATCH", result.getReason()); + assertEquals("on", result.getVariant()); + assertEquals("flag", result.getFlagMetadata().getString("flagKey")); + assertEquals("STRING", result.getFlagMetadata().getString("variationType")); + assertEquals("allocation", result.getFlagMetadata().getString("allocationKey")); + assertEquals(7, result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + assertEquals(true, result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG)); + assertEquals(1, exposures.get()); + } + + @Test + void convertsExposureFailuresToGeneralErrors() { + final OpenFeatureEvaluationAdapter adapter = + new OpenFeatureEvaluationAdapter( + (flagKey, allocationKey, variantKey, context) -> { + throw new IllegalStateException("exposure failed"); + }, + false); + + final ProviderEvaluation result = + adapter.evaluate( + configuration(ValueType.STRING, "hello", true, emptyList(), emptyList()), + String.class, + "flag", + "default", + new MutableContext("subject")); + + assertEquals("default", result.getValue()); + assertEquals(dev.openfeature.sdk.Reason.ERROR.name(), result.getReason()); + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals("exposure failed", result.getErrorMessage()); + } + + @Test + void mapsCoreErrorsToOpenFeatureErrors() { + final OpenFeatureEvaluationAdapter adapter = new OpenFeatureEvaluationAdapter(null, false); + final MutableContext context = new MutableContext("subject"); + + assertError( + ErrorCode.FLAG_NOT_FOUND, + adapter.evaluate( + new ServerConfiguration(null, "SERVER", new Environment("test"), emptyMap()), + String.class, + "flag", + "default", + context)); + assertError( + ErrorCode.TYPE_MISMATCH, + adapter.evaluate( + configuration(ValueType.STRING, "hello", true, emptyList(), emptyList()), + Boolean.class, + "flag", + false, + context)); + assertError( + ErrorCode.PARSE_ERROR, + adapter.evaluate( + configuration(ValueType.INTEGER, "not-an-integer", true, emptyList(), emptyList()), + Integer.class, + "flag", + 1, + context)); + assertError( + ErrorCode.TARGETING_KEY_MISSING, + adapter.evaluate( + configuration( + ValueType.STRING, + "hello", + true, + emptyList(), + singletonList(new Shard("salt", singletonList(new ShardRange(0, 1)), 1))), + String.class, + "flag", + "default", + new MutableContext())); + } + + @Test + void preservesObjectDefaultsAndUnwrapsOpenFeatureValues() { + final OpenFeatureEvaluationAdapter adapter = new OpenFeatureEvaluationAdapter(null, false); + final Value defaultValue = Value.objectToValue(singletonMap("default", true)); + final ProviderEvaluation disabled = + adapter.evaluate( + configuration( + ValueType.JSON, singletonMap("enabled", true), false, emptyList(), emptyList()), + Value.class, + "flag", + defaultValue, + new MutableContext("subject")); + assertSame(defaultValue, disabled.getValue()); + + final ProviderEvaluation enabled = + adapter.evaluate( + configuration( + ValueType.JSON, singletonMap("enabled", true), true, emptyList(), emptyList()), + Value.class, + "flag", + new Value(), + new MutableContext("subject")); + assertEquals(Value.objectToValue(singletonMap("enabled", true)), enabled.getValue()); + + final Instant instant = Instant.parse("2026-01-01T00:00:00Z"); + final Map object = new LinkedHashMap<>(); + object.put("null", null); + object.put("boolean", true); + object.put("string", "value"); + object.put("integer", 1); + object.put("double", 1.5); + object.put("list", Arrays.asList("value", 2)); + object.put("instant", instant); + + final Map unwrapped = + (Map) OpenFeatureEvaluationAdapter.unwrapDefaultValue(Value.objectToValue(object)); + assertNull(unwrapped.get("null")); + assertEquals(true, unwrapped.get("boolean")); + assertEquals("value", unwrapped.get("string")); + assertEquals(1, unwrapped.get("integer")); + assertEquals(1.5, unwrapped.get("double")); + assertEquals(Arrays.asList("value", 2), unwrapped.get("list")); + assertEquals(instant.toString(), unwrapped.get("instant")); + assertNull(OpenFeatureEvaluationAdapter.unwrapDefaultValue(new Value())); + assertEquals("raw", OpenFeatureEvaluationAdapter.unwrapDefaultValue("raw")); + } + + private static void assertError( + final ErrorCode expected, final ProviderEvaluation evaluation) { + assertEquals(expected, evaluation.getErrorCode()); + } + + private static ServerConfiguration configuration( + final ValueType type, + final Object value, + final boolean enabled, + final java.util.List rules, + final java.util.List shards) { + final Split split = new Split(shards, "on", emptyMap(), 7); + final Allocation allocation = + new Allocation("allocation", rules, null, null, singletonList(split), true); + final Flag flag = + new Flag( + "flag", + enabled, + type, + singletonMap("on", new Variant("on", value)), + singletonList(allocation)); + return new ServerConfiguration( + null, "SERVER", new Environment("test"), singletonMap("flag", flag)); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java new file mode 100644 index 00000000000..56ea6052157 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java @@ -0,0 +1,55 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class ProviderOnlyChildJvmTest { + + @Test + void evaluatesCdnFlagWithoutJavaAgent() throws Exception { + final String classpath = + Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator)) + .filter(path -> !path.contains("feature-flagging-bootstrap")) + .filter(path -> !path.contains("feature-flagging-core")) + .filter(path -> !path.contains("feature-flagging-http")) + .filter( + path -> + !path.contains( + "feature-flagging-api" + + File.separator + + "build" + + File.separator + + "classes" + + File.separator + + "java" + + File.separator + + "main")) + .collect(Collectors.joining(File.pathSeparator)) + + File.pathSeparator + + System.getProperty("dd.openfeature.test.jar"); + final Process process = + new ProcessBuilder( + new File(System.getProperty("java.home"), "bin/java").getAbsolutePath(), + "-cp", + classpath, + ProviderOnlyChildMain.class.getName()) + .redirectErrorStream(true) + .start(); + + assertTrue(process.waitFor(20, TimeUnit.SECONDS)); + final String output = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + assertTrue(output.contains("AGENT_ATTACHED=false"), output); + assertTrue(output.contains("AGENT_GATEWAY_AVAILABLE=false"), output); + assertTrue(output.contains("REQUESTS_BEFORE_ACTIVATION=0"), output); + assertTrue(output.contains("VALUE=hello"), output); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildMain.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildMain.java new file mode 100644 index 00000000000..ba7ece1070d --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildMain.java @@ -0,0 +1,82 @@ +package datadog.trace.api.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.sun.net.httpserver.HttpServer; +import dev.openfeature.sdk.MutableContext; +import java.lang.management.ManagementFactory; +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** Child JVM entry point for the provider-only CDN smoke test. */ +public final class ProviderOnlyChildMain { + + private ProviderOnlyChildMain() {} + + public static void main(final String[] args) throws Exception { + final boolean agentAttached = + ManagementFactory.getRuntimeMXBean().getInputArguments().stream() + .anyMatch(argument -> argument.startsWith("-javaagent:")); + final AtomicInteger requests = new AtomicInteger(); + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + requests.incrementAndGet(); + final byte[] response = UFC.getBytes(UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + try { + boolean agentGatewayAvailable = false; + try { + Class.forName("datadog.trace.api.featureflag.FeatureFlaggingGateway"); + agentGatewayAvailable = true; + } catch (final ClassNotFoundException expected) { + } + System.setProperty( + "dd.feature.flags.configuration.source.agentless.base.url", + "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + System.setProperty("dd.feature.flags.configuration.source", "agentless"); + final Provider provider = + new Provider(new Provider.Options().initTimeout(1, TimeUnit.SECONDS)); + final int beforeActivation = requests.get(); + provider.initialize(new MutableContext("child")); + final String value = + provider + .getStringEvaluation("message", "default", new MutableContext("child")) + .getValue(); + final int afterActivation = requests.get(); + provider.shutdown(); + final int afterShutdown = requests.get(); + Thread.sleep(150); + + System.out.println("AGENT_ATTACHED=" + agentAttached); + System.out.println("AGENT_GATEWAY_AVAILABLE=" + agentGatewayAvailable); + System.out.println("REQUESTS_BEFORE_ACTIVATION=" + beforeActivation); + System.out.println("REQUESTS_AFTER_ACTIVATION=" + afterActivation); + System.out.println("REQUESTS_AFTER_SHUTDOWN=" + requests.get()); + System.out.println("VALUE=" + value); + if (agentAttached + || agentGatewayAvailable + || beforeActivation != 0 + || afterActivation == 0 + || requests.get() != afterShutdown + || !"hello".equals(value)) { + System.exit(2); + } + } finally { + server.stop(0); + } + } + + private static final String UFC = + "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{\"key\":\"allocation\",\"rules\":[],\"splits\":[" + + "{\"variationKey\":\"on\",\"shards\":[]}],\"doLog\":true}]}}}"; +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildJvmTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildJvmTest.java new file mode 100644 index 00000000000..1a547fff62e --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildJvmTest.java @@ -0,0 +1,53 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class RemoteConfigWithoutAgentChildJvmTest { + + @Test + void reportsClearErrorWhenRemoteConfigurationRequiresAgent() throws Exception { + final String classpath = + Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator)) + .filter(path -> !path.contains("feature-flagging-bootstrap")) + .filter(path -> !path.contains("feature-flagging-core")) + .filter(path -> !path.contains("feature-flagging-http")) + .filter( + path -> + !path.contains( + "feature-flagging-api" + + File.separator + + "build" + + File.separator + + "classes" + + File.separator + + "java" + + File.separator + + "main")) + .collect(Collectors.joining(File.pathSeparator)) + + File.pathSeparator + + System.getProperty("dd.openfeature.test.jar"); + final Process process = + new ProcessBuilder( + new File(System.getProperty("java.home"), "bin/java").getAbsolutePath(), + "-cp", + classpath, + RemoteConfigWithoutAgentChildMain.class.getName()) + .redirectErrorStream(true) + .start(); + + assertTrue(process.waitFor(30, TimeUnit.SECONDS)); + final String output = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + assertTrue(output.contains("REMOTE_CONFIGURATION_ERROR="), output); + assertTrue(output.contains("requires dd-java-agent.jar"), output); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildMain.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildMain.java new file mode 100644 index 00000000000..6a15c61ab62 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildMain.java @@ -0,0 +1,23 @@ +package datadog.trace.api.openfeature; + +import dev.openfeature.sdk.exceptions.FatalError; + +public final class RemoteConfigWithoutAgentChildMain { + private RemoteConfigWithoutAgentChildMain() {} + + public static void main(String[] args) { + System.setProperty("dd.feature.flags.configuration.source", "remote_config"); + final Provider provider = new Provider(); + try { + provider.initialize(null); + throw new AssertionError("Remote Configuration initialization unexpectedly succeeded"); + } catch (final FatalError error) { + if (!error.getMessage().contains("requires dd-java-agent.jar")) { + throw new AssertionError("Unexpected initialization error: " + error.getMessage(), error); + } + System.out.println("REMOTE_CONFIGURATION_ERROR=" + error.getMessage()); + } catch (final Exception error) { + throw new AssertionError("Unexpected checked exception", error); + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java new file mode 100644 index 00000000000..32c50a10352 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java @@ -0,0 +1,185 @@ +package datadog.trace.api.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeout; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class StandaloneDDEvaluatorTest { + + private static final String ENABLED = "dd.feature.flags.enabled"; + private static final String SOURCE = "dd.feature.flags.configuration.source"; + private static final String BASE_URL = "dd.feature.flags.configuration.source.agentless.base.url"; + + @AfterEach + void clearProperties() { + System.clearProperty(ENABLED); + System.clearProperty(SOURCE); + System.clearProperty(BASE_URL); + } + + @Test + void ownsAndSharesCdnRuntimeLifecycle() throws Exception { + final AtomicInteger requests = new AtomicInteger(); + final HttpServer server = server(requests); + final StandaloneDDEvaluator first = new StandaloneDDEvaluator(() -> {}); + final AtomicInteger callbacks = new AtomicInteger(); + final StandaloneDDEvaluator second = new StandaloneDDEvaluator(callbacks::incrementAndGet); + try { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + final MutableContext context = new MutableContext("subject"); + + assertFalse(first.hasConfiguration()); + final ProviderEvaluation before = + first.evaluate(String.class, "message", "default", context); + assertEquals("default", before.getValue()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, before.getErrorCode()); + + assertTrue(first.initialize(1, SECONDS, context)); + assertTrue(first.initialize(1, SECONDS, context)); + assertEquals("hello", first.evaluate(String.class, "message", "default", context).getValue()); + + assertTrue(second.initialize(1, SECONDS, context)); + assertTrue(callbacks.get() > 0); + assertEquals(1, requests.get()); + + first.shutdown(); + first.shutdown(); + assertFalse(first.hasConfiguration()); + assertEquals( + "hello", second.evaluate(String.class, "message", "default", context).getValue()); + } finally { + first.shutdown(); + second.shutdown(); + server.stop(0); + } + } + + @Test + void rejectsDisabledRemoteConfigAndMismatchedSharedConfiguration() throws Exception { + final MutableContext context = new MutableContext("subject"); + System.setProperty(ENABLED, "false"); + assertThrows( + IllegalStateException.class, + () -> new StandaloneDDEvaluator(() -> {}).initialize(1, SECONDS, context)); + + System.clearProperty(ENABLED); + System.setProperty(SOURCE, "remote_config"); + assertThrows( + IllegalStateException.class, + () -> new StandaloneDDEvaluator(() -> {}).initialize(1, SECONDS, context)); + + final HttpServer server = server(new AtomicInteger()); + final StandaloneDDEvaluator first = new StandaloneDDEvaluator(() -> {}); + final StandaloneDDEvaluator second = new StandaloneDDEvaluator(() -> {}); + try { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + assertTrue(first.initialize(1, SECONDS, context)); + + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/other"); + assertThrows(IllegalStateException.class, () -> second.initialize(1, SECONDS, context)); + } finally { + first.shutdown(); + second.shutdown(); + server.stop(0); + } + } + + @Test + void remainsNotReadyWhenCdnReturnsInvalidConfiguration() throws Exception { + final HttpServer server = server(new AtomicInteger(), "{"); + final StandaloneDDEvaluator evaluator = new StandaloneDDEvaluator(() -> {}); + try { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + + assertFalse(evaluator.initialize(1, MILLISECONDS, new MutableContext("subject"))); + assertFalse(evaluator.hasConfiguration()); + final ProviderEvaluation result = + evaluator.evaluate(String.class, "message", "default", new MutableContext("subject")); + assertEquals("default", result.getValue()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, result.getErrorCode()); + } finally { + evaluator.shutdown(); + server.stop(0); + } + } + + @Test + void initializationTimeoutIncludesTheInitialCdnRequest() throws Exception { + final HttpServer server = delayedServer(500); + final StandaloneDDEvaluator evaluator = new StandaloneDDEvaluator(() -> {}); + try { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + + assertTimeout( + Duration.ofMillis(250), + () -> assertFalse(evaluator.initialize(10, MILLISECONDS, new MutableContext("subject")))); + } finally { + evaluator.shutdown(); + server.stop(0); + } + } + + private static HttpServer server(final AtomicInteger requests) throws Exception { + return server(requests, UFC); + } + + private static HttpServer server(final AtomicInteger requests, final String body) + throws Exception { + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + requests.incrementAndGet(); + final byte[] response = body.getBytes(UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + return server; + } + + private static HttpServer delayedServer(final long delayMillis) throws Exception { + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + try { + Thread.sleep(delayMillis); + exchange.sendResponseHeaders(304, -1); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + return server; + } + + private static final String UFC = + "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{\"key\":\"allocation\",\"rules\":[],\"splits\":[" + + "{\"variationKey\":\"on\",\"shards\":[]}],\"doLog\":true}]}}}"; +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java new file mode 100644 index 00000000000..cb3d55211c5 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java @@ -0,0 +1,112 @@ +package datadog.trace.api.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.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.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class StandaloneProviderRuntimeTest { + + private static final String SOURCE = "dd.feature.flags.configuration.source"; + private static final String BASE_URL = "dd.feature.flags.configuration.source.agentless.base.url"; + private static final String POLL_INTERVAL = + "dd.feature.flags.configuration.source.agentless.poll.interval.seconds"; + + @AfterEach + void clearProperties() { + System.clearProperty(SOURCE); + System.clearProperty(BASE_URL); + System.clearProperty(POLL_INTERVAL); + } + + @Test + void closedHandleIsSafeAndIdempotent() throws Exception { + final HttpServer server = server(); + final AtomicInteger callbacks = new AtomicInteger(); + StandaloneProviderRuntime.Handle handle = null; + try { + configure(server, "30"); + handle = + StandaloneProviderRuntime.acquire( + StandaloneRuntimeConfiguration.resolve(), ignored -> callbacks.incrementAndGet()); + + assertTrue(handle.awaitConfiguration(1, SECONDS)); + assertNotNull(handle.configuration()); + assertTrue(callbacks.get() > 0); + + handle.close(); + assertNull(handle.configuration()); + assertFalse(handle.awaitConfiguration(1, MILLISECONDS)); + handle.close(); + } finally { + if (handle != null) { + handle.close(); + } + server.stop(0); + } + } + + @Test + void failedStartReleasesSharedRuntime() throws Exception { + final HttpServer server = server(); + StandaloneProviderRuntime.Handle recovered = null; + try { + configure(server, String.valueOf(Long.MAX_VALUE)); + + assertThrows( + ArithmeticException.class, + () -> + StandaloneProviderRuntime.acquire( + StandaloneRuntimeConfiguration.resolve(), ignored -> {})); + + System.setProperty(POLL_INTERVAL, "30"); + recovered = + StandaloneProviderRuntime.acquire( + StandaloneRuntimeConfiguration.resolve(), ignored -> {}); + assertTrue(recovered.awaitConfiguration(1, SECONDS)); + assertNotNull(recovered.configuration()); + } finally { + if (recovered != null) { + recovered.close(); + } + server.stop(0); + } + } + + private static void configure(final HttpServer server, final String pollInterval) { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + System.setProperty(POLL_INTERVAL, pollInterval); + } + + private static HttpServer server() throws Exception { + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + final byte[] response = UFC.getBytes(UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + return server; + } + + private static final String UFC = + "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{\"key\":\"allocation\",\"rules\":[],\"splits\":[" + + "{\"variationKey\":\"on\",\"shards\":[]}],\"doLog\":true}]}}}"; +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java new file mode 100644 index 00000000000..7160d99d887 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java @@ -0,0 +1,155 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class StandaloneRuntimeConfigurationTest { + + private static final String ENABLED = "dd.feature.flags.enabled"; + private static final String SOURCE = "dd.feature.flags.configuration.source"; + private static final String LEGACY_ENABLED = "dd.experimental.flagging.provider.enabled"; + private static final String BASE_URL = "dd.feature.flags.configuration.source.agentless.base.url"; + private static final String POLL_INTERVAL = + "dd.feature.flags.configuration.source.agentless.poll.interval.seconds"; + private static final String REQUEST_TIMEOUT = + "dd.feature.flags.configuration.source.agentless.request.timeout.seconds"; + private static final String API_KEY = "dd.api.key"; + + @AfterEach + void clearProperties() { + System.clearProperty(ENABLED); + System.clearProperty(SOURCE); + System.clearProperty(LEGACY_ENABLED); + System.clearProperty(BASE_URL); + System.clearProperty(POLL_INTERVAL); + System.clearProperty(REQUEST_TIMEOUT); + System.clearProperty(API_KEY); + } + + @Test + void defaultsToAgentless() { + assertEquals( + StandaloneRuntimeConfiguration.Source.CDN, + StandaloneRuntimeConfiguration.resolveSource(null, null, null)); + } + + @Test + void explicitAgentlessOverridesLegacyRemoteConfiguration() { + System.setProperty(SOURCE, "agentless"); + System.setProperty(LEGACY_ENABLED, "true"); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(StandaloneRuntimeConfiguration.Source.CDN, configuration.source); + } + + @Test + void preservesLegacyRemoteConfigurationDefault() { + System.setProperty(LEGACY_ENABLED, "true"); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(StandaloneRuntimeConfiguration.Source.REMOTE_CONFIG, configuration.source); + } + + @Test + void stableKillSwitchDisablesExplicitSource() { + System.setProperty(ENABLED, "false"); + System.setProperty(SOURCE, "agentless"); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(StandaloneRuntimeConfiguration.Source.DISABLED, configuration.source); + } + + @Test + void rejectsUnsupportedSource() { + System.setProperty(SOURCE, "offline"); + + assertThrows(IllegalArgumentException.class, StandaloneRuntimeConfiguration::resolve); + } + + @Test + void resolvesCustomEndpointAndPollingOptions() { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:8080/config?tenant=test"); + System.setProperty(POLL_INTERVAL, "7"); + System.setProperty(REQUEST_TIMEOUT, "2"); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals( + "http://127.0.0.1:8080/config?tenant=test", configuration.http.endpoint.toString()); + assertEquals(Duration.ofSeconds(7), configuration.http.pollInterval); + assertEquals(Duration.ofSeconds(2), configuration.http.requestTimeout); + } + + @Test + void treatsBlankBaseUrlsAsTheManagedEndpoint() { + System.setProperty(API_KEY, "secret"); + for (final String baseUrl : new String[] {"", " "}) { + System.setProperty(BASE_URL, baseUrl); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", + configuration.http.endpoint.toString()); + assertTrue(configuration.http.managedEndpoint); + assertEquals("secret", configuration.http.apiKey); + } + } + + @Test + void usesDefaultsForInvalidPollingOptions() { + System.setProperty(POLL_INTERVAL, "invalid"); + System.setProperty(REQUEST_TIMEOUT, "0"); + + StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(Duration.ofSeconds(30), configuration.http.pollInterval); + assertEquals(Duration.ofSeconds(5), configuration.http.requestTimeout); + + System.setProperty(POLL_INTERVAL, "-1"); + System.setProperty(REQUEST_TIMEOUT, "invalid"); + + configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(Duration.ofSeconds(30), configuration.http.pollInterval); + assertEquals(Duration.ofSeconds(5), configuration.http.requestTimeout); + } + + @Test + void comparesResolvedConfigurationsByEffectiveOptions() { + System.setProperty(BASE_URL, "https://example.test/config"); + System.setProperty(API_KEY, "key"); + final StandaloneRuntimeConfiguration first = StandaloneRuntimeConfiguration.resolve(); + final StandaloneRuntimeConfiguration equivalent = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(first, first); + assertEquals(first, equivalent); + assertEquals(first.hashCode(), equivalent.hashCode()); + assertNotEquals(first, null); + assertNotEquals(first, "configuration"); + + System.setProperty(API_KEY, "other"); + assertNotEquals(first, StandaloneRuntimeConfiguration.resolve()); + + System.clearProperty(API_KEY); + System.clearProperty(BASE_URL); + System.setProperty(ENABLED, "false"); + final StandaloneRuntimeConfiguration disabled = StandaloneRuntimeConfiguration.resolve(); + assertEquals(disabled, StandaloneRuntimeConfiguration.resolve()); + assertEquals(disabled.hashCode(), StandaloneRuntimeConfiguration.resolve().hashCode()); + + System.clearProperty(ENABLED); + System.setProperty(SOURCE, "remote_config"); + assertNotEquals(disabled, StandaloneRuntimeConfiguration.resolve()); + } +} diff --git a/products/feature-flagging/feature-flagging-core/build.gradle.kts b/products/feature-flagging/feature-flagging-core/build.gradle.kts new file mode 100644 index 00000000000..45f8b2f1e06 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/build.gradle.kts @@ -0,0 +1,33 @@ +import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension + +plugins { + `java-library` +} + +apply(from = "$rootDir/gradle/java.gradle") + +description = "Provider-owned Feature Flagging model, parser, evaluator, and configuration state" + +// Defensive parser and evaluator branches reject malformed customer input. +// The tests cover each supported operation and the main rejection paths. +extra["minimumBranchCoverage"] = 0.7 + +extra["excludedClassesCoverage"] = listOf( + // Immutable data transfer types + "datadog.openfeature.internal.core.EvaluationResult", + "datadog.openfeature.internal.core.EvaluationResult.*", + "datadog.openfeature.internal.core.ApplyResult", + "datadog.openfeature.internal.core.SourceStatus", +) + +configure { + minJavaVersion.set(JavaVersion.VERSION_1_8) +} + +dependencies { + implementation(libs.moshi) + compileOnlyApi(project(":products:feature-flagging:feature-flagging-bootstrap")) + + testImplementation(libs.bundles.junit5) + testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) +} diff --git a/products/feature-flagging/feature-flagging-core/gradle.lockfile b/products/feature-flagging/feature-flagging-core/gradle.lockfile new file mode 100644 index 00000000000..676e4412a25 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/gradle.lockfile @@ -0,0 +1,79 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :products:feature-flagging:feature-flagging-core:dependencies --write-locks +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.9.8=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.squareup.moshi:moshi:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-io:commons-io:2.20.0=spotbugs +de.thetaphi:forbiddenapis:3.10=compileClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.12.8=testRuntimeClasspath +net.bytebuddy:byte-buddy:1.12.8=testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-lang3:3.19.0=spotbugs +org.apache.commons:commons-text:1.14.0=spotbugs +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt +org.jacoco:org.jacoco.core:0.8.15=jacocoAnt +org.jacoco:org.jacoco.report:0.8.15=jacocoAnt +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.9=spotbugs +org.ow2.asm:asm-commons:9.10.1=jacocoAnt +org.ow2.asm:asm-commons:9.9=spotbugs +org.ow2.asm:asm-tree:9.10.1=jacocoAnt +org.ow2.asm:asm-tree:9.9=spotbugs +org.ow2.asm:asm-util:9.9=spotbugs +org.ow2.asm:asm:9.10.1=jacocoAnt +org.ow2.asm:asm:9.9=spotbugs +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ApplyResult.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ApplyResult.java new file mode 100644 index 00000000000..b9a9d67d5d5 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ApplyResult.java @@ -0,0 +1,8 @@ +package datadog.openfeature.internal.core; + +/** Result of applying configuration bytes to the provider-owned configuration state. */ +public enum ApplyResult { + ACCEPTED, + REJECTED, + CLEARED +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSink.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSink.java new file mode 100644 index 00000000000..4d15b40e5f5 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSink.java @@ -0,0 +1,9 @@ +package datadog.openfeature.internal.core; + +/** Accepts raw UFC bytes from a configuration source. */ +public interface ConfigurationSink { + + ApplyResult apply(byte[] content); + + ApplyResult clear(); +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSource.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSource.java new file mode 100644 index 00000000000..1de614d36ed --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSource.java @@ -0,0 +1,12 @@ +package datadog.openfeature.internal.core; + +/** Delivers UFC bytes to a {@link ConfigurationSink}. */ +public interface ConfigurationSource extends AutoCloseable { + + void start(); + + SourceStatus status(); + + @Override + void close(); +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java new file mode 100644 index 00000000000..5340086b1b8 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java @@ -0,0 +1,100 @@ +package datadog.openfeature.internal.core; + +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** Thread-safe last-known-good configuration state. */ +public final class ConfigurationStore implements ConfigurationSink { + + private final UfcParser parser; + private final AtomicReference current = new AtomicReference<>(); + private final List> listeners = new CopyOnWriteArrayList<>(); + private final Object changeMonitor = new Object(); + + public ConfigurationStore() { + this(new UfcParser()); + } + + ConfigurationStore(final UfcParser parser) { + this.parser = parser; + } + + @Override + public ApplyResult apply(final byte[] content) { + final ServerConfiguration next; + try { + next = parser.parse(content); + } catch (final IOException | RuntimeException ignored) { + return ApplyResult.REJECTED; + } + if (next == null || next.flags == null) { + return ApplyResult.REJECTED; + } + current.set(next); + signalChange(next); + return ApplyResult.ACCEPTED; + } + + @Override + public ApplyResult clear() { + current.set(null); + signalChange(null); + return ApplyResult.CLEARED; + } + + public ServerConfiguration current() { + return current.get(); + } + + public boolean hasConfiguration() { + return current.get() != null; + } + + public void addListener(final Consumer listener) { + listeners.add(listener); + final ServerConfiguration snapshot = current.get(); + if (snapshot != null) { + listener.accept(snapshot); + } + } + + public void removeListener(final Consumer listener) { + listeners.remove(listener); + } + + public boolean awaitConfiguration(final long timeout, final TimeUnit unit) + throws InterruptedException { + if (hasConfiguration()) { + return true; + } + final long deadline = System.nanoTime() + unit.toNanos(timeout); + synchronized (changeMonitor) { + while (!hasConfiguration()) { + final long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return false; + } + TimeUnit.NANOSECONDS.timedWait(changeMonitor, remaining); + } + return true; + } + } + + @SuppressFBWarnings( + value = "NN_NAKED_NOTIFY", + justification = "The caller updates the atomic snapshot before it signals waiting readers") + private void signalChange(final ServerConfiguration snapshot) { + synchronized (changeMonitor) { + changeMonitor.notifyAll(); + } + for (final Consumer listener : listeners) { + listener.accept(snapshot); + } + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java new file mode 100644 index 00000000000..271c40207ca --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java @@ -0,0 +1,60 @@ +package datadog.openfeature.internal.core; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** OpenFeature-independent evaluation context. */ +public final class EvaluationContext { + + private final String targetingKey; + private final AttributeProvider attributes; + + public EvaluationContext(final String targetingKey, final Map attributes) { + final Map retained = + attributes == null ? Collections.emptyMap() : new LinkedHashMap<>(attributes); + this.targetingKey = targetingKey; + this.attributes = + new AttributeProvider() { + @Override + public boolean contains(final String name) { + return retained.containsKey(name); + } + + @Override + public Object get(final String name) { + return retained.get(name); + } + }; + } + + private EvaluationContext(final String targetingKey, final AttributeProvider attributeProvider) { + this.targetingKey = targetingKey; + this.attributes = attributeProvider; + } + + public static EvaluationContext lazy( + final String targetingKey, final AttributeProvider attributeProvider) { + if (attributeProvider == null) { + throw new IllegalArgumentException("Attribute provider is required"); + } + return new EvaluationContext(targetingKey, attributeProvider); + } + + public String targetingKey() { + return targetingKey; + } + + public Object attribute(final String name) { + if ("id".equals(name) && !attributes.contains(name)) { + return targetingKey; + } + return attributes.get(name); + } + + public interface AttributeProvider { + boolean contains(String name); + + Object get(String name); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationResult.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationResult.java new file mode 100644 index 00000000000..66567ff7306 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationResult.java @@ -0,0 +1,86 @@ +package datadog.openfeature.internal.core; + +/** OpenFeature-independent evaluation result. */ +public final class EvaluationResult { + + public enum Reason { + ERROR, + DISABLED, + TARGETING_MATCH, + SPLIT, + STATIC, + DEFAULT + } + + public enum Error { + PROVIDER_NOT_READY, + INVALID_CONTEXT, + FLAG_NOT_FOUND, + TARGETING_KEY_MISSING, + TYPE_MISMATCH, + PARSE_ERROR, + GENERAL + } + + public final Object value; + public final Reason reason; + public final Error error; + public final String errorMessage; + public final String variant; + public final String flagKey; + public final String variationType; + public final String allocationKey; + public final Integer splitSerialId; + public final boolean doLog; + + private EvaluationResult( + final Object value, + final Reason reason, + final Error error, + final String errorMessage, + final String variant, + final String flagKey, + final String variationType, + final String allocationKey, + final Integer splitSerialId, + final boolean doLog) { + this.value = value; + this.reason = reason; + this.error = error; + this.errorMessage = errorMessage; + this.variant = variant; + this.flagKey = flagKey; + this.variationType = variationType; + this.allocationKey = allocationKey; + this.splitSerialId = splitSerialId; + this.doLog = doLog; + } + + public static EvaluationResult value( + final Object value, + final Reason reason, + final String variant, + final String flagKey, + final String variationType, + final String allocationKey, + final Integer splitSerialId, + final boolean doLog) { + return new EvaluationResult( + value, + reason, + null, + null, + variant, + flagKey, + variationType, + allocationKey, + splitSerialId, + doLog); + } + + public static EvaluationResult error( + final Object defaultValue, final Error error, final String message) { + return new EvaluationResult( + defaultValue, Reason.ERROR, error, message, null, null, null, null, null, false); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java new file mode 100644 index 00000000000..f9f4742974c --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java @@ -0,0 +1,311 @@ +package datadog.openfeature.internal.core; + +import datadog.openfeature.internal.core.EvaluationResult.Error; +import datadog.openfeature.internal.core.EvaluationResult.Reason; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; +import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Shard; +import datadog.trace.api.featureflag.ufc.v1.ShardRange; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** Evaluates the existing UFC model without OpenFeature SDK types. */ +public final class FlagEvaluator { + + public enum ValueKind { + BOOLEAN, + STRING, + INTEGER, + DOUBLE, + OBJECT + } + + public EvaluationResult evaluate( + final ServerConfiguration snapshot, + final ValueKind target, + final String key, + final Object defaultValue, + final EvaluationContext context) { + try { + if (snapshot == null) { + return EvaluationResult.error(defaultValue, Error.PROVIDER_NOT_READY, null); + } + if (context == null) { + return EvaluationResult.error(defaultValue, Error.INVALID_CONTEXT, null); + } + final Flag flag = snapshot.flags.get(key); + if (flag == null) { + return EvaluationResult.error(defaultValue, Error.FLAG_NOT_FOUND, null); + } + if (!flag.enabled) { + return EvaluationResult.value( + defaultValue, Reason.DISABLED, null, flag.key, typeName(flag), null, null, false); + } + if (flag.allocations == null) { + return EvaluationResult.error( + defaultValue, Error.GENERAL, "Missing allocations for flag " + key); + } + + final long now = System.currentTimeMillis(); + for (final Allocation allocation : flag.allocations) { + if (!isActive(allocation, now) + || (!isEmpty(allocation.rules) && !evaluateRules(allocation.rules, context))) { + continue; + } + if (isEmpty(allocation.splits)) { + continue; + } + for (final Split split : allocation.splits) { + if (isEmpty(split.shards)) { + return resolve(target, key, defaultValue, flag, allocation, split); + } + if (context.targetingKey() == null) { + return EvaluationResult.error(defaultValue, Error.TARGETING_KEY_MISSING, null); + } + boolean matches = true; + for (final Shard shard : split.shards) { + if (!matchesShard(shard, context.targetingKey())) { + matches = false; + break; + } + } + if (matches) { + return resolve(target, key, defaultValue, flag, allocation, split); + } + } + } + return EvaluationResult.value( + defaultValue, Reason.DEFAULT, null, flag.key, typeName(flag), null, null, false); + } catch (final PatternSyntaxException e) { + return EvaluationResult.error(defaultValue, Error.PARSE_ERROR, e.getMessage()); + } catch (final NumberFormatException e) { + return EvaluationResult.error(defaultValue, Error.TYPE_MISMATCH, e.getMessage()); + } catch (final RuntimeException e) { + return EvaluationResult.error(defaultValue, Error.GENERAL, e.getMessage()); + } + } + + private static EvaluationResult resolve( + final ValueKind target, + final String key, + final Object defaultValue, + final Flag flag, + final Allocation allocation, + final Split split) { + final Variant variant = flag.variations.get(split.variationKey); + if (variant == null) { + return EvaluationResult.error( + defaultValue, Error.GENERAL, "Variant not found for: " + split.variationKey); + } + if (!isCompatible(target, flag.variationType)) { + return EvaluationResult.error( + defaultValue, + Error.TYPE_MISMATCH, + "Requested type " + target + " does not match " + flag.variationType); + } + + final Object value; + try { + value = mapValue(target, variant.value); + } catch (final NumberFormatException e) { + return EvaluationResult.error( + defaultValue, + Error.PARSE_ERROR, + "Variant '" + variant.key + "' does not match " + flag.variationType); + } + + final Reason reason = + !isEmpty(allocation.rules) + ? Reason.TARGETING_MATCH + : !isEmpty(split.shards) ? Reason.SPLIT : Reason.STATIC; + return EvaluationResult.value( + value, + reason, + variant.key, + key, + typeName(flag), + allocation.key, + split.serialId, + Boolean.TRUE.equals(allocation.doLog)); + } + + public static Object mapValue(final ValueKind target, final Object value) { + if (value == null || target == ValueKind.OBJECT) { + return value; + } + switch (target) { + case STRING: + return String.valueOf(value); + case BOOLEAN: + return value instanceof Number + ? Boolean.valueOf(((Number) value).doubleValue() != 0) + : Boolean.valueOf(String.valueOf(value)); + case INTEGER: + return value instanceof Number + ? ((Number) value).intValue() + : (int) Double.parseDouble(String.valueOf(value)); + case DOUBLE: + return value instanceof Number + ? ((Number) value).doubleValue() + : Double.parseDouble(String.valueOf(value)); + default: + throw new IllegalArgumentException("Unsupported value kind: " + target); + } + } + + private static boolean isActive(final Allocation allocation, final long now) { + return (allocation.startAt == null || now >= allocation.startAt.getTime()) + && (allocation.endAt == null || now <= allocation.endAt.getTime()); + } + + private static boolean evaluateRules(final List rules, final EvaluationContext context) { + for (final Rule rule : rules) { + if (isEmpty(rule.conditions)) { + continue; + } + boolean matches = true; + for (final ConditionConfiguration condition : rule.conditions) { + if (!evaluateCondition(condition, context)) { + matches = false; + break; + } + } + if (matches) { + return true; + } + } + return false; + } + + private static boolean evaluateCondition( + final ConditionConfiguration condition, final EvaluationContext context) { + final Object attribute = context.attribute(condition.attribute); + if (condition.operator == ConditionOperator.IS_NULL) { + final boolean expectedNull = + !(condition.value instanceof Boolean) || (Boolean) condition.value; + return (attribute == null) == expectedNull; + } + if (attribute == null) { + return false; + } + switch (condition.operator) { + case MATCHES: + return Pattern.compile(String.valueOf(condition.value)) + .matcher(String.valueOf(attribute)) + .find(); + case NOT_MATCHES: + return !Pattern.compile(String.valueOf(condition.value)) + .matcher(String.valueOf(attribute)) + .find(); + case ONE_OF: + return oneOf(attribute, condition.value); + case NOT_ONE_OF: + return !oneOf(attribute, condition.value); + case GTE: + return compare(attribute, condition.value, (a, b) -> a >= b); + case GT: + return compare(attribute, condition.value, (a, b) -> a > b); + case LTE: + return compare(attribute, condition.value, (a, b) -> a <= b); + case LT: + return compare(attribute, condition.value, (a, b) -> a < b); + default: + return false; + } + } + + private static boolean oneOf(final Object attribute, final Object values) { + if (!(values instanceof Iterable)) { + return false; + } + for (final Object value : (Iterable) values) { + if (Objects.equals(attribute, value) + || (attribute instanceof Number || value instanceof Number) + && compare(attribute, value, (first, second) -> first == second) + || String.valueOf(attribute).equals(String.valueOf(value))) { + return true; + } + } + return false; + } + + private static boolean compare( + final Object first, final Object second, final NumberPredicate predicate) { + return predicate.test(number(first), number(second)); + } + + private static double number(final Object value) { + return value instanceof Number + ? ((Number) value).doubleValue() + : Double.parseDouble(String.valueOf(value)); + } + + private static boolean matchesShard(final Shard shard, final String targetingKey) { + if (shard.totalShards <= 0 || shard.ranges == null) { + return false; + } + final String input = shard.salt + "-" + targetingKey; + final byte[] digest; + try { + digest = MessageDigest.getInstance("MD5").digest(input.getBytes(StandardCharsets.UTF_8)); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("MD5 algorithm not available", e); + } + long firstFourBytes = 0; + for (int i = 0; i < 4; i++) { + firstFourBytes = (firstFourBytes << 8) | (digest[i] & 0xffL); + } + final int assigned = (int) (firstFourBytes % shard.totalShards); + for (final ShardRange range : shard.ranges) { + if (assigned >= range.start && assigned < range.end) { + return true; + } + } + return false; + } + + private static boolean isCompatible(final ValueKind target, final ValueType type) { + if (type == null) { + return true; + } + switch (type) { + case BOOLEAN: + return target == ValueKind.BOOLEAN; + case STRING: + return target == ValueKind.STRING; + case INTEGER: + return target == ValueKind.INTEGER; + case NUMERIC: + return target == ValueKind.DOUBLE; + case JSON: + return target == ValueKind.OBJECT; + default: + return false; + } + } + + private static String typeName(final Flag flag) { + return flag.variationType == null ? null : flag.variationType.name(); + } + + private static boolean isEmpty(final List value) { + return value == null || value.isEmpty(); + } + + @FunctionalInterface + private interface NumberPredicate { + boolean test(double first, double second); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/SourceStatus.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/SourceStatus.java new file mode 100644 index 00000000000..297b1c70d4e --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/SourceStatus.java @@ -0,0 +1,10 @@ +package datadog.openfeature.internal.core; + +/** Lifecycle status for a configuration source. */ +public enum SourceStatus { + NEW, + STARTING, + READY, + ERROR, + CLOSED +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java new file mode 100644 index 00000000000..058cf4487d7 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java @@ -0,0 +1,204 @@ +package datadog.openfeature.internal.core; + +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.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +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.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import okio.BufferedSource; +import okio.Okio; + +/** Parses raw UFC documents and JSON API UFC responses into the existing UFC model. */ +public final class UfcParser { + + private static final String UFC_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"); + private static final Moshi MOSHI = + new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build(); + private static final JsonAdapter V1_ADAPTER = + MOSHI.adapter(ServerConfiguration.class); + + public ServerConfiguration parse(final byte[] content) throws IOException { + return parse(content, false); + } + + public ServerConfiguration parseJsonApi(final byte[] content) throws IOException { + return parse(content, true); + } + + private static ServerConfiguration parse( + final byte[] content, final boolean requireJsonApiEnvelope) throws IOException { + if (content == null || content.length == 0) { + throw new IOException("UFC payload is empty"); + } + try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { + final JsonReader reader = JsonReader.of(source); + final boolean jsonApi = requireJsonApiEnvelope || isJsonApi(reader.peekJson()); + final ServerConfiguration configuration = + jsonApi ? parseJsonApi(reader) : V1_ADAPTER.fromJson(reader); + requireEndOfDocument(reader); + if (jsonApi && (configuration == null || configuration.flags == null)) { + throw new IOException("JSON API response does not contain UFC data"); + } + return configuration; + } catch (final JsonDataException | IllegalArgumentException e) { + throw new IOException("UFC payload is malformed", e); + } + } + + private static boolean isJsonApi(final JsonReader reader) throws IOException { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + return false; + } + reader.beginObject(); + while (reader.hasNext()) { + if (reader.selectName(RESPONSE_FIELDS) == 0) { + return true; + } + reader.skipName(); + reader.skipValue(); + } + return false; + } + + private static ServerConfiguration parseJsonApi(final JsonReader reader) throws IOException { + 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(); + return configuration; + } + + private static 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 = V1_ADAPTER.fromJson(reader); + break; + default: + reader.skipName(); + reader.skipValue(); + } + } + reader.endObject(); + return UFC_TYPE.equals(type) ? 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(); + } + + 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() { + @Override + public JsonAdapter create( + final Type type, final Set annotations, 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; + } + + @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) { + flags.put(flagKey, flag); + } + } catch (final JsonDataException | IllegalArgumentException ignored) { + // A malformed flag must not prevent other flags in the same config from evaluating. + } + } + reader.endObject(); + return flags; + } + + @Override + public void toJson(final JsonWriter writer, final Map value) { + throw new UnsupportedOperationException("Reading only adapter"); + } + } + + static final class DateAdapter extends JsonAdapter { + + @Override + public Date fromJson(final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + final String date = reader.nextString(); + try { + final Instant instant = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); + return Date.from(instant); + } catch (final RuntimeException ignored) { + return null; + } + } + + @Override + public void toJson(final JsonWriter writer, final Date value) { + throw new UnsupportedOperationException("Reading only adapter"); + } + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java new file mode 100644 index 00000000000..9f3b7adc7b5 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java @@ -0,0 +1,105 @@ +package datadog.openfeature.internal.core; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class ConfigurationStoreTest { + + @Test + void preservesLastKnownGoodAfterRejectedPayload() { + final ConfigurationStore store = new ConfigurationStore(); + final AtomicInteger changes = new AtomicInteger(); + store.addListener(ignored -> changes.incrementAndGet()); + assertEquals(ApplyResult.ACCEPTED, store.apply(Fixtures.UFC.getBytes(UTF_8))); + final ServerConfiguration accepted = store.current(); + + assertEquals(ApplyResult.REJECTED, store.apply("{".getBytes(UTF_8))); + assertSame(accepted, store.current()); + assertEquals(1, changes.get()); + + assertEquals( + ApplyResult.ACCEPTED, + store.apply(Fixtures.UFC.replace("hello", "recovered").getBytes(UTF_8))); + assertEquals(2, changes.get()); + assertTrue(store.hasConfiguration()); + } + + @Test + void rejectsMissingAndNullFlagMapsButAcceptsAnEmptyMap() { + final ConfigurationStore store = new ConfigurationStore(); + assertEquals(ApplyResult.ACCEPTED, store.apply(Fixtures.UFC.getBytes(UTF_8))); + final ServerConfiguration accepted = store.current(); + + assertEquals(ApplyResult.REJECTED, store.apply("{}".getBytes(UTF_8))); + assertSame(accepted, store.current()); + assertEquals(ApplyResult.REJECTED, store.apply("{\"flags\":null}".getBytes(UTF_8))); + assertSame(accepted, store.current()); + assertEquals(ApplyResult.REJECTED, store.apply("null".getBytes(UTF_8))); + assertSame(accepted, store.current()); + + assertEquals(ApplyResult.ACCEPTED, store.apply("{\"flags\":{}}".getBytes(UTF_8))); + assertTrue(store.current().flags.isEmpty()); + } + + @Test + void clearsConfigurationExplicitly() { + final ConfigurationStore store = new ConfigurationStore(); + store.apply(Fixtures.UFC.getBytes(UTF_8)); + + assertEquals(ApplyResult.CLEARED, store.clear()); + assertEquals(null, store.current()); + assertFalse(store.hasConfiguration()); + } + + @Test + void sendsCurrentAndDeletedSnapshotsToListeners() { + final ConfigurationStore store = new ConfigurationStore(); + store.apply(Fixtures.UFC.getBytes(UTF_8)); + final AtomicReference current = new AtomicReference<>(); + final Consumer listener = current::set; + + store.addListener(listener); + assertSame(store.current(), current.get()); + + store.clear(); + assertEquals(null, current.get()); + store.removeListener(listener); + } + + @Test + void waitsForConfigurationAndTimesOut() throws Exception { + final ConfigurationStore store = new ConfigurationStore(); + assertFalse(store.awaitConfiguration(1, TimeUnit.MILLISECONDS)); + final CountDownLatch waiting = new CountDownLatch(1); + final AtomicReference result = new AtomicReference<>(); + final Thread thread = + new Thread( + () -> { + waiting.countDown(); + try { + result.set(store.awaitConfiguration(1, TimeUnit.SECONDS)); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + } + }); + thread.start(); + assertTrue(waiting.await(1, TimeUnit.SECONDS)); + + store.apply(Fixtures.UFC.getBytes(UTF_8)); + thread.join(1_000); + + assertEquals(true, result.get()); + assertTrue(store.awaitConfiguration(0, TimeUnit.MILLISECONDS)); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java new file mode 100644 index 00000000000..3ce6fe5f3f0 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java @@ -0,0 +1,59 @@ +package datadog.openfeature.internal.core; + +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class EvaluationContextTest { + + @Test + void copiesAttributesAndUsesTargetingKeyAsDefaultId() { + final Map attributes = new LinkedHashMap<>(); + attributes.put("role", "admin"); + final EvaluationContext context = new EvaluationContext("subject", attributes); + attributes.put("role", "changed"); + + assertEquals("subject", context.targetingKey()); + assertEquals("subject", context.attribute("id")); + assertEquals("admin", context.attribute("role")); + } + + @Test + void preservesExplicitId() { + final EvaluationContext context = + new EvaluationContext("subject", singletonMap("id", "explicit-subject")); + + assertEquals("explicit-subject", context.attribute("id")); + } + + @Test + void delegatesLazyAttributeLookup() { + final EvaluationContext context = + EvaluationContext.lazy( + "subject", + new EvaluationContext.AttributeProvider() { + @Override + public boolean contains(final String name) { + return "id".equals(name); + } + + @Override + public Object get(final String name) { + return "id".equals(name) ? "lazy-subject" : null; + } + }); + + assertEquals("lazy-subject", context.attribute("id")); + assertNull(context.attribute("missing")); + } + + @Test + void rejectsMissingLazyProvider() { + assertThrows(IllegalArgumentException.class, () -> EvaluationContext.lazy("subject", null)); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/Fixtures.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/Fixtures.java new file mode 100644 index 00000000000..d03e5b76b2d --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/Fixtures.java @@ -0,0 +1,27 @@ +package datadog.openfeature.internal.core; + +final class Fixtures { + + static final String UFC = + "{" + + "\"createdAt\":\"2026-01-01T00:00:00Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"test\"}," + + "\"flags\":{" + + "\"message\":{" + + "\"key\":\"message\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{" + + "\"key\":\"allocation\"," + + "\"rules\":[]," + + "\"splits\":[{\"variationKey\":\"on\",\"shards\":[],\"serialId\":7}]," + + "\"doLog\":true" + + "}]" + + "}" + + "}" + + "}"; + + private Fixtures() {} +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java new file mode 100644 index 00000000000..bbf99edb275 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java @@ -0,0 +1,307 @@ +package datadog.openfeature.internal.core; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import datadog.openfeature.internal.core.EvaluationResult.Error; +import datadog.openfeature.internal.core.EvaluationResult.Reason; +import datadog.openfeature.internal.core.FlagEvaluator.ValueKind; +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.Environment; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; +import 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.util.ArrayList; +import java.util.LinkedHashMap; +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; + +class FlagEvaluatorTest { + + private static final String CANONICAL_FIXTURE_PATH = + "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; + private static final Type FIXTURE_LIST_TYPE = + Types.newParameterizedType(List.class, FixtureCase.class); + private static final JsonAdapter> FIXTURE_LIST_ADAPTER = + new Moshi.Builder().build().adapter(FIXTURE_LIST_TYPE); + private static final UfcParser UFC_PARSER = new UfcParser(); + + private final FlagEvaluator evaluator = new FlagEvaluator(); + + @ParameterizedTest(name = "{0}") + @MethodSource("canonicalTestCases") + void evaluatesCanonicalFixture(final FixtureCase testCase) throws IOException { + final ValueKind valueKind = valueKind(testCase.variationType); + final Object defaultValue = FlagEvaluator.mapValue(valueKind, testCase.defaultValue); + final EvaluationResult result = + evaluator.evaluate( + loadCanonicalConfiguration(), + valueKind, + testCase.flag, + defaultValue, + context(testCase)); + + assertEquals(FlagEvaluator.mapValue(valueKind, testCase.result.value), result.value); + assertEquals(Reason.valueOf(testCase.result.reason), result.reason); + if (testCase.result.errorCode != null) { + assertEquals(Error.valueOf(testCase.result.errorCode), result.error); + } + } + + @Test + void canonicalFixturesArePresent() throws IOException { + assertFalse(canonicalTestCases().isEmpty()); + } + + @Test + void evaluatesMetadataExcludedFromCanonicalFixtures() { + final EvaluationResult result = + evaluate(staticFlag(ValueType.STRING, "hello"), ValueKind.STRING, context("subject")); + + assertEquals("hello", result.value); + assertEquals("on", result.variant); + assertEquals(Reason.STATIC, result.reason); + assertEquals("allocation", result.allocationKey); + assertEquals(7, result.splitSerialId); + assertEquals(true, result.doLog); + } + + @Test + void reportsErrorsOutsideCanonicalFixtureContract() { + assertError(null, ValueKind.STRING, context("id"), Error.PROVIDER_NOT_READY); + assertError( + snapshot(staticFlag(ValueType.STRING, "value")), + ValueKind.STRING, + null, + Error.INVALID_CONTEXT); + assertError( + snapshot( + new Flag("flag", true, ValueType.STRING, map("on", new Variant("on", "value")), null)), + ValueKind.STRING, + context("id"), + Error.GENERAL); + assertError( + snapshot(staticFlag(ValueType.STRING, "value")), + ValueKind.BOOLEAN, + context("id"), + Error.TYPE_MISMATCH); + assertError( + snapshot( + flag(ValueType.STRING, map("on", new Variant("on", "value")), list(split("missing")))), + ValueKind.STRING, + context("id"), + Error.GENERAL); + assertError( + snapshot(staticFlag(ValueType.INTEGER, "not-a-number")), + ValueKind.INTEGER, + context("id"), + Error.PARSE_ERROR); + } + + @Test + void reportsMalformedRuleErrorsOutsideCanonicalFixtureContract() { + final Flag invalidRegex = targetedFlag(condition(ConditionOperator.MATCHES, "value", "[")); + assertEquals( + Error.PARSE_ERROR, + evaluate(invalidRegex, ValueKind.STRING, new EvaluationContext("id", map("value", "text"))) + .error); + + final Flag invalidNumber = targetedFlag(condition(ConditionOperator.GT, "value", "number")); + assertEquals( + Error.TYPE_MISMATCH, + evaluate(invalidNumber, ValueKind.STRING, new EvaluationContext("id", map("value", 2))) + .error); + } + + private static ServerConfiguration loadCanonicalConfiguration() throws IOException { + return UFC_PARSER.parse(Files.readAllBytes(fixtureRoot().resolve("ufc-config.json"))); + } + + 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); + } + } + } + + 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 ValueKind valueKind(final String variationType) { + switch (variationType) { + case "BOOLEAN": + return ValueKind.BOOLEAN; + case "INTEGER": + return ValueKind.INTEGER; + case "NUMERIC": + return ValueKind.DOUBLE; + case "STRING": + return ValueKind.STRING; + case "JSON": + return ValueKind.OBJECT; + default: + throw new IllegalArgumentException("Unsupported variationType: " + variationType); + } + } + + private static EvaluationContext context(final FixtureCase testCase) { + return new EvaluationContext( + testCase.targetingKey, testCase.attributes == null ? emptyMap() : testCase.attributes); + } + + private EvaluationResult evaluate( + final Flag flag, final ValueKind kind, final EvaluationContext context) { + return evaluator.evaluate(snapshot(flag), kind, "flag", "default", context); + } + + private void assertError( + final ServerConfiguration snapshot, + final ValueKind kind, + final EvaluationContext context, + final Error error) { + assertEquals(error, evaluator.evaluate(snapshot, kind, "flag", "default", context).error); + } + + private static ServerConfiguration snapshot(final Flag flag) { + return new ServerConfiguration(null, "SERVER", new Environment("test"), map("flag", flag)); + } + + private static EvaluationContext context(final String targetingKey) { + return new EvaluationContext(targetingKey, emptyMap()); + } + + private static Flag staticFlag(final ValueType type, final Object value) { + return flag(type, map("on", new Variant("on", value)), list(split("on"))); + } + + private static Flag targetedFlag(final ConditionConfiguration condition) { + return new Flag( + "flag", + true, + ValueType.STRING, + map("on", new Variant("on", "value")), + list( + new Allocation( + "allocation", + list(new Rule(list(condition))), + null, + null, + list(split("on")), + false))); + } + + private static Flag flag( + final ValueType type, final Map variants, final List splits) { + return new Flag( + "flag", + true, + type, + variants, + list(new Allocation("allocation", emptyList(), null, null, splits, true))); + } + + private static Split split(final String variationKey) { + return new Split(emptyList(), variationKey, emptyMap(), 7); + } + + private static ConditionConfiguration condition( + final ConditionOperator operator, final String attribute, final Object value) { + return new ConditionConfiguration(operator, attribute, value); + } + + @SafeVarargs + private static List list(final T... values) { + final List result = new ArrayList<>(values.length); + for (final T value : values) { + result.add(value); + } + return result; + } + + @SuppressWarnings("unchecked") + private static Map map(final Object... keyValues) { + final Map result = new LinkedHashMap<>(); + for (int index = 0; index < keyValues.length; index += 2) { + result.put((K) keyValues[index], (V) keyValues[index + 1]); + } + 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; + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java new file mode 100644 index 00000000000..d9645bd176f --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java @@ -0,0 +1,243 @@ +package datadog.openfeature.internal.core; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.squareup.moshi.JsonWriter; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; +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 java.io.IOException; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class UfcParserTest { + + private final UfcParser parser = new UfcParser(); + + @Test + void parsesRawUfc() throws Exception { + final ServerConfiguration snapshot = parser.parse(Fixtures.UFC.getBytes(UTF_8)); + + assertEquals("test", snapshot.environment.name); + assertEquals(1, snapshot.flags.size()); + assertNotNull(snapshot.flags.get("message")); + } + + @Test + void parsesJsonApiResponse() throws Exception { + final String response = + "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + Fixtures.UFC + + "}}"; + + assertEquals(1, parser.parse(response.getBytes(UTF_8)).flags.size()); + assertEquals(1, parser.parseJsonApi(response.getBytes(UTF_8)).flags.size()); + assertThrows(IOException.class, () -> parser.parseJsonApi(Fixtures.UFC.getBytes(UTF_8))); + } + + @Test + void parsesCompleteUfcModel() throws Exception { + final String content = + "{" + + "\"createdAt\":\"2026-01-01T00:00:00Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"production\"}," + + "\"flags\":{\"targeted\":{" + + "\"key\":\"targeted\"," + + "\"enabled\":true," + + "\"variationType\":\"JSON\"," + + "\"variations\":{\"on\":{\"value\":{\"nested\":[1,true]}}}," + + "\"allocations\":[{" + + "\"key\":\"allocation\"," + + "\"startAt\":\"2025-01-01T00:00:00Z\"," + + "\"endAt\":\"invalid-date\"," + + "\"doLog\":true," + + "\"rules\":[{\"conditions\":[{" + + "\"operator\":\"ONE_OF\",\"attribute\":\"country\",\"value\":[\"US\"]" + + "}]}]," + + "\"splits\":[{" + + "\"variationKey\":\"on\"," + + "\"serialId\":12," + + "\"extraLogging\":{\"reason\":\"test\"}," + + "\"shards\":[{\"salt\":\"salt\",\"totalShards\":100," + + "\"ranges\":[{\"start\":0,\"end\":50}]}]" + + "}]" + + "}]" + + "}}}"; + + final ServerConfiguration snapshot = parser.parse(content.getBytes(UTF_8)); + final Flag flag = snapshot.flags.get("targeted"); + final Allocation allocation = flag.allocations.get(0); + final Split split = allocation.splits.get(0); + + assertEquals("targeted", flag.key); + assertEquals(ValueType.JSON, flag.variationType); + assertNotNull(allocation.startAt); + assertEquals(null, allocation.endAt); + assertEquals(ConditionOperator.ONE_OF, allocation.rules.get(0).conditions.get(0).operator); + assertEquals(12, split.serialId); + assertEquals("test", split.extraLogging.get("reason")); + assertEquals(100, split.shards.get(0).totalShards); + assertEquals(50, split.shards.get(0).ranges.get(0).end); + } + + @Test + void ignoresUnknownAdditiveFieldsAtEveryUfcLevel() throws Exception { + final String content = + "{" + + "\"futureEnvelope\":{\"version\":2}," + + "\"data\":{" + + "\"type\":\"universal-flag-configuration\"," + + "\"futureResource\":true," + + "\"attributes\":{" + + "\"futureConfiguration\":{\"key\":\"value\"}," + + "\"environment\":{\"name\":\"test\",\"futureEnvironment\":true}," + + "\"flags\":{\"message\":{" + + "\"key\":\"message\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"futureFlag\":1," + + "\"variations\":{\"on\":{" + + "\"key\":\"on\",\"value\":\"hello\",\"futureVariant\":\"ignored\"" + + "}}," + + "\"allocations\":[{" + + "\"key\":\"allocation\"," + + "\"futureAllocation\":{}," + + "\"rules\":[{" + + "\"futureRule\":true," + + "\"conditions\":[{" + + "\"operator\":\"IS_NULL\"," + + "\"attribute\":\"missing\"," + + "\"value\":true," + + "\"futureCondition\":[]" + + "}]" + + "}]," + + "\"splits\":[{" + + "\"variationKey\":\"on\"," + + "\"serialId\":7," + + "\"futureSplit\":false," + + "\"shards\":[{" + + "\"salt\":\"salt\"," + + "\"totalShards\":1," + + "\"futureShard\":null," + + "\"ranges\":[{\"start\":0,\"end\":1,\"futureRange\":9}]" + + "}]" + + "}]" + + "}]" + + "}}" + + "}" + + "}" + + "}"; + + final ServerConfiguration snapshot = parser.parseJsonApi(content.getBytes(UTF_8)); + final Flag flag = snapshot.flags.get("message"); + final Allocation allocation = flag.allocations.get(0); + final Split split = allocation.splits.get(0); + + assertEquals("test", snapshot.environment.name); + assertEquals("hello", flag.variations.get("on").value); + assertEquals(ConditionOperator.IS_NULL, allocation.rules.get(0).conditions.get(0).operator); + assertEquals(7, split.serialId); + assertEquals(1, split.shards.get(0).ranges.get(0).end); + } + + @Test + void parsesOffsetDatesAndMapsInvalidDatesToNull() throws Exception { + final String content = + "{" + + "\"flags\":{\"flag\":{" + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"value\":\"on\"}}," + + "\"allocations\":[" + + "{\"key\":\"positive\",\"startAt\":\"2023-01-01T01:00:00+01:00\"," + + "\"splits\":[]}," + + "{\"key\":\"negative\",\"startAt\":\"2023-01-01T00:00:00-05:00\"," + + "\"splits\":[]}," + + "{\"key\":\"invalid\",\"startAt\":\"not-a-date\",\"splits\":[]}" + + ",{\"key\":\"null\",\"startAt\":null,\"splits\":[]}" + + "]" + + "}}}"; + + final List allocations = + parser.parse(content.getBytes(UTF_8)).flags.get("flag").allocations; + + assertEquals(Instant.parse("2023-01-01T00:00:00Z"), allocations.get(0).startAt.toInstant()); + assertEquals(Instant.parse("2023-01-01T05:00:00Z"), allocations.get(1).startAt.toInstant()); + assertEquals(null, allocations.get(2).startAt); + assertEquals(null, allocations.get(3).startAt); + } + + @Test + void rejectsSerializationThroughReadOnlyAdapters() { + assertThrows( + UnsupportedOperationException.class, + () -> new UfcParser.DateAdapter().toJson((JsonWriter) null, null)); + assertThrows( + UnsupportedOperationException.class, + () -> new UfcParser.FlagMapAdapter(null).toJson((JsonWriter) null, null)); + } + + @Test + void skipsMalformedFlagAndKeepsValidFlag() throws Exception { + final String content = + Fixtures.UFC.replace("\"flags\":{", "\"flags\":{\"broken\":{\"enabled\":\"yes\"},"); + + final ServerConfiguration snapshot = parser.parse(content.getBytes(UTF_8)); + + assertFalse(snapshot.flags.containsKey("broken")); + assertNotNull(snapshot.flags.get("message")); + } + + @Test + void rejectsWrongJsonApiType() { + assertThrows( + IOException.class, + () -> parser.parse("{\"data\":{\"type\":\"other\",\"attributes\":{}}}".getBytes(UTF_8))); + } + + @Test + void rejectsEmptyMalformedAndStructurallyInvalidDocuments() { + assertThrows(IOException.class, () -> parser.parse(null)); + assertThrows(IOException.class, () -> parser.parse(new byte[0])); + assertThrows(IOException.class, () -> parser.parse("{".getBytes(UTF_8))); + assertThrows(IOException.class, () -> parser.parse("[]".getBytes(UTF_8))); + assertThrows( + IOException.class, + () -> + parser.parse("{\"data\":{\"type\":\"universal-flag-configuration\"}}".getBytes(UTF_8))); + } + + @Test + void allowsMissingAndNullFlagMaps() throws Exception { + assertEquals(null, parser.parse("{}".getBytes(UTF_8)).flags); + assertEquals(null, parser.parse("{\"flags\":null}".getBytes(UTF_8)).flags); + } + + @Test + void skipsFlagsWithInvalidRequiredFields() throws Exception { + final String content = + "{\"flags\":{" + + "\"enabled\":{\"enabled\":\"yes\",\"variationType\":\"STRING\",\"variations\":{}}," + + "\"type\":{\"enabled\":true,\"variationType\":\"UNKNOWN\",\"variations\":{}}," + + "\"variants\":{\"enabled\":true,\"variationType\":\"STRING\",\"variations\":[]}," + + "\"allocations\":{\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{},\"allocations\":\"bad\"}," + + "\"operator\":{\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"value\":\"on\"}},\"allocations\":[{" + + "\"key\":\"a\",\"rules\":[{\"conditions\":[{" + + "\"operator\":\"UNKNOWN\",\"attribute\":\"id\"}]}],\"splits\":[]}]}" + + "}}"; + + assertTrue(parser.parse(content.getBytes(UTF_8)).flags.isEmpty()); + } +} diff --git a/products/feature-flagging/feature-flagging-http/build.gradle.kts b/products/feature-flagging/feature-flagging-http/build.gradle.kts new file mode 100644 index 00000000000..7b93ee4ad71 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/build.gradle.kts @@ -0,0 +1,49 @@ +import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension +import groovy.lang.Closure + +plugins { + `java-library` +} + +apply(from = "$rootDir/gradle/java.gradle") + +description = "Provider-owned Feature Flagging CDN transport and polling" + +// Transport failures and concurrent lifecycle races add defensive branches. +// Integration tests cover success, timeout, cancellation, retry, and shutdown behavior. +extra["minimumBranchCoverage"] = 0.7 +extra["minimumInstructionCoverage"] = 0.8 + +configure { + minJavaVersion.set(JavaVersion.VERSION_11) +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +dependencies { + api(project(":products:feature-flagging:feature-flagging-core")) + + testImplementation(libs.bundles.junit5) + testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) +} + +fun AbstractCompile.configureCompiler( + javaVersionInteger: Int, + compatibilityVersion: JavaVersion? = null, + unsetReleaseFlagReason: String? = null +) { + (project.extra["configureCompiler"] as Closure<*>).call( + this, + javaVersionInteger, + compatibilityVersion, + unsetReleaseFlagReason + ) +} + +tasks.withType().configureEach { + configureCompiler(11, JavaVersion.VERSION_11) +} diff --git a/products/feature-flagging/feature-flagging-http/gradle.lockfile b/products/feature-flagging/feature-flagging-http/gradle.lockfile new file mode 100644 index 00000000000..f60721da879 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/gradle.lockfile @@ -0,0 +1,79 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :products:feature-flagging:feature-flagging-http:dependencies --write-locks +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.9.8=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.squareup.moshi:moshi:1.11.0=runtimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=runtimeClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-io:commons-io:2.20.0=spotbugs +de.thetaphi:forbiddenapis:3.10=compileClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.12.8=testRuntimeClasspath +net.bytebuddy:byte-buddy:1.12.8=testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-lang3:3.19.0=spotbugs +org.apache.commons:commons-text:1.14.0=spotbugs +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt +org.jacoco:org.jacoco.core:0.8.15=jacocoAnt +org.jacoco:org.jacoco.report:0.8.15=jacocoAnt +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.9=spotbugs +org.ow2.asm:asm-commons:9.10.1=jacocoAnt +org.ow2.asm:asm-commons:9.9=spotbugs +org.ow2.asm:asm-tree:9.10.1=jacocoAnt +org.ow2.asm:asm-tree:9.9=spotbugs +org.ow2.asm:asm-util:9.9=spotbugs +org.ow2.asm:asm:9.10.1=jacocoAnt +org.ow2.asm:asm:9.9=spotbugs +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java new file mode 100644 index 00000000000..8d6713987ce --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java @@ -0,0 +1,325 @@ +package datadog.openfeature.internal.http; + +import datadog.openfeature.internal.core.ApplyResult; +import datadog.openfeature.internal.core.ConfigurationSink; +import datadog.openfeature.internal.core.ConfigurationSource; +import datadog.openfeature.internal.core.SourceStatus; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.DoubleSupplier; +import java.util.zip.GZIPInputStream; + +/** Java 11 HTTP client source for CDN-backed UFC delivery. */ +public final class CdnConfigurationSource implements ConfigurationSource { + + static final int MAX_ATTEMPTS = 3; + private static final double RETRY_JITTER = 0.2; + + private final HttpConfigurationOptions options; + private final ConfigurationSink sink; + private final Transport transport; + private final ScheduledExecutorService executor; + private final Sleeper sleeper; + private final DoubleSupplier jitter; + private final AtomicBoolean polling = new AtomicBoolean(); + private final Object lifecycleLock = new Object(); + private volatile SourceStatus status = SourceStatus.NEW; + private volatile boolean closed; + private volatile boolean started; + private volatile ScheduledFuture scheduledPoll; + private volatile String etag; + + public CdnConfigurationSource( + final HttpConfigurationOptions options, final ConfigurationSink sink) { + this( + options, + sink, + new Java11Transport( + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()), + Executors.newSingleThreadScheduledExecutor( + runnable -> { + final Thread thread = new Thread(runnable, "dd-openfeature-cdn-poller"); + thread.setDaemon(true); + return thread; + }), + TimeUnit.MILLISECONDS::sleep, + () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); + } + + CdnConfigurationSource( + final HttpConfigurationOptions options, + final ConfigurationSink sink, + final Transport transport, + final ScheduledExecutorService executor, + final Sleeper sleeper, + final DoubleSupplier jitter) { + this.options = options; + this.sink = sink; + this.transport = transport; + this.executor = executor; + this.sleeper = sleeper; + this.jitter = jitter; + } + + @Override + public void start() { + synchronized (lifecycleLock) { + if (closed || started) { + return; + } + started = true; + status = SourceStatus.STARTING; + } + + synchronized (lifecycleLock) { + if (!closed) { + scheduledPoll = + executor.scheduleWithFixedDelay( + this::pollOnceSafely, 0, options.pollInterval.toMillis(), TimeUnit.MILLISECONDS); + } + } + } + + public boolean pollOnce() { + if (closed || !polling.compareAndSet(false, true)) { + return false; + } + try { + final boolean success = fetchWithRetry(); + if (!closed) { + status = success ? SourceStatus.READY : SourceStatus.ERROR; + } + return success; + } finally { + polling.set(false); + } + } + + @Override + public SourceStatus status() { + return status; + } + + @Override + public void close() { + final ScheduledFuture poll; + synchronized (lifecycleLock) { + if (closed) { + return; + } + closed = true; + started = false; + status = SourceStatus.CLOSED; + poll = scheduledPoll; + scheduledPoll = null; + } + if (poll != null) { + poll.cancel(true); + } + transport.cancel(); + executor.shutdownNow(); + } + + private void pollOnceSafely() { + try { + pollOnce(); + } catch (final RuntimeException ignored) { + if (!closed) { + status = SourceStatus.ERROR; + } + } + } + + private boolean fetchWithRetry() { + for (int attempt = 1; attempt <= MAX_ATTEMPTS && !closed; attempt++) { + try { + final TransportResponse response = + transport.fetch(options, etag == null ? Collections.emptyMap() : etagHeader(etag)); + synchronized (lifecycleLock) { + if (closed) { + return false; + } + if (response.status == 304) { + return true; + } + if (response.status == 200 && response.body != null) { + final ApplyResult result = sink.apply(response.body); + if (result == ApplyResult.ACCEPTED) { + etag = blankToNull(response.etag); + return true; + } + return false; + } + } + if (!retryable(response.status) || attempt == MAX_ATTEMPTS) { + return false; + } + } catch (final IOException e) { + if (attempt == MAX_ATTEMPTS || closed) { + return false; + } + } + + try { + sleeper.sleep( + retryDelayMillis(options.pollInterval.toMillis(), attempt, jitter.getAsDouble())); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return false; + } + + static long retryDelayMillis( + final long pollIntervalMillis, final int attempt, final double jitter) { + final long minimum = attempt == 1 ? 2_000 : 5_000; + final long maximum = attempt == 1 ? 10_000 : 30_000; + final long fraction = pollIntervalMillis / (attempt == 1 ? 6 : 3); + return Math.max(1, Math.round(Math.max(minimum, Math.min(maximum, fraction)) * jitter)); + } + + private static boolean retryable(final int status) { + return status == 408 || status == 429 || status >= 500 && status <= 599; + } + + private static Map etagHeader(final String etag) { + final Map headers = new LinkedHashMap<>(); + headers.put("If-None-Match", etag); + return headers; + } + + private static String blankToNull(final String value) { + return value == null || value.trim().isEmpty() ? null : value; + } + + interface Sleeper { + void sleep(long millis) throws InterruptedException; + } + + interface Transport { + TransportResponse fetch(HttpConfigurationOptions options, Map headers) + throws IOException; + + void cancel(); + } + + static final class TransportResponse { + final int status; + final String etag; + final byte[] body; + + TransportResponse(final int status, final String etag, final byte[] body) { + this.status = status; + this.etag = etag; + this.body = body; + } + } + + static final class Java11Transport implements Transport { + private final HttpClient client; + private final AtomicReference> active = new AtomicReference<>(); + private final AtomicBoolean cancelled = new AtomicBoolean(); + + Java11Transport(final HttpClient client) { + this.client = client; + } + + @Override + public TransportResponse fetch( + final HttpConfigurationOptions options, final Map headers) + throws IOException { + if (cancelled.get()) { + throw new InterruptedIOException("Feature Flagging HTTP source is closed"); + } + final HttpRequest.Builder request = + HttpRequest.newBuilder(options.endpoint) + .timeout(options.requestTimeout) + .GET() + .header("Datadog-Meta-Lang", "java") + .header("Accept", "application/json") + .header("Accept-Encoding", "gzip"); + if (options.managedEndpoint && options.apiKey != null && !options.apiKey.isEmpty()) { + request.header("DD-API-KEY", options.apiKey); + } + headers.forEach(request::header); + + final CompletableFuture> future = + client.sendAsync(request.build(), BodyHandlers.ofByteArray()); + active.set(future); + if (cancelled.get()) { + future.cancel(true); + } + try { + final java.net.http.HttpResponse response = future.get(); + return new TransportResponse( + response.statusCode(), + response.headers().firstValue("ETag").orElse(null), + decodeBody( + response.body(), response.headers().firstValue("Content-Encoding").orElse(null))); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Feature Flagging HTTP request interrupted"); + } catch (final CancellationException e) { + throw new InterruptedIOException("Feature Flagging HTTP request cancelled"); + } catch (final ExecutionException e) { + final Throwable cause = e.getCause(); + if (cause instanceof CancellationException) { + throw new InterruptedIOException("Feature Flagging HTTP request cancelled"); + } + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException("Feature Flagging HTTP request failed", cause); + } finally { + active.compareAndSet(future, null); + } + } + + private static byte[] decodeBody(final byte[] body, final String contentEncoding) + throws IOException { + if (body == null + || body.length == 0 + || contentEncoding == null + || !"gzip".equalsIgnoreCase(contentEncoding.trim())) { + return body; + } + try (GZIPInputStream input = new GZIPInputStream(new ByteArrayInputStream(body)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + final byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + } + + @Override + public void cancel() { + cancelled.set(true); + final CompletableFuture future = active.get(); + if (future != null) { + future.cancel(true); + } + } + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnEndpointResolver.java b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnEndpointResolver.java new file mode 100644 index 00000000000..dcb9ee76459 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnEndpointResolver.java @@ -0,0 +1,41 @@ +package datadog.openfeature.internal.http; + +import java.net.URI; +import java.net.URISyntaxException; + +/** Resolves the managed CDN endpoint or a test-controlled endpoint. */ +public final class CdnEndpointResolver { + + public static final String UFC_PATH = "/api/v2/feature-flagging/config/rules-based/server"; + + private CdnEndpointResolver() {} + + public static URI resolve(final String configuredBaseUrl, final String site, final String env) { + if (configuredBaseUrl != null && !configuredBaseUrl.trim().isEmpty()) { + final URI configured = URI.create(configuredBaseUrl.trim()); + if (!configured.isAbsolute() || configured.getHost() == null) { + throw new IllegalArgumentException( + "Invalid Feature Flagging HTTP configuration source URL: " + configuredBaseUrl); + } + final String path = configured.getPath(); + if (path == null || path.isEmpty() || "/".equals(path)) { + final String query = configured.getRawQuery(); + return configured.resolve(URI.create(UFC_PATH + (query == null ? "" : "?" + query))); + } + return configured; + } + + try { + return new URI( + "https", + null, + "ufc-server.ff-cdn." + (site == null || site.isEmpty() ? "datadoghq.com" : site), + -1, + UFC_PATH, + env == null || env.isEmpty() ? null : "dd_env=" + env, + null); + } catch (final URISyntaxException e) { + throw new IllegalArgumentException("Invalid Feature Flagging CDN endpoint", e); + } + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/HttpConfigurationOptions.java b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/HttpConfigurationOptions.java new file mode 100644 index 00000000000..e10fb12dcec --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/HttpConfigurationOptions.java @@ -0,0 +1,72 @@ +package datadog.openfeature.internal.http; + +import java.net.URI; +import java.time.Duration; +import java.util.Objects; + +/** Immutable options for the provider-owned CDN configuration source. */ +public final class HttpConfigurationOptions { + + public final URI endpoint; + public final Duration pollInterval; + public final Duration requestTimeout; + public final String apiKey; + public final boolean managedEndpoint; + + private HttpConfigurationOptions(final Builder builder) { + endpoint = Objects.requireNonNull(builder.endpoint, "endpoint"); + pollInterval = positive(builder.pollInterval, "pollInterval"); + requestTimeout = positive(builder.requestTimeout, "requestTimeout"); + apiKey = builder.apiKey; + managedEndpoint = builder.managedEndpoint; + } + + public static Builder builder() { + return new Builder(); + } + + private static Duration positive(final Duration value, final String name) { + Objects.requireNonNull(value, name); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + public static final class Builder { + private URI endpoint; + private Duration pollInterval = Duration.ofSeconds(30); + private Duration requestTimeout = Duration.ofSeconds(5); + private String apiKey; + private boolean managedEndpoint; + + public Builder endpoint(final URI endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder pollInterval(final Duration pollInterval) { + this.pollInterval = pollInterval; + return this; + } + + public Builder requestTimeout(final Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + public Builder apiKey(final String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder managedEndpoint(final boolean managedEndpoint) { + this.managedEndpoint = managedEndpoint; + return this; + } + + public HttpConfigurationOptions build() { + return new HttpConfigurationOptions(this); + } + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java new file mode 100644 index 00000000000..d10e2aa886c --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java @@ -0,0 +1,214 @@ +package datadog.openfeature.internal.http; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.openfeature.internal.core.ConfigurationStore; +import datadog.openfeature.internal.core.SourceStatus; +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class CdnConfigurationSourceTest { + + @Test + void keepsEtagAndLastKnownGoodAfterMalformedPayload() { + final QueueTransport transport = new QueueTransport(); + transport.responses.add(response(200, "\"one\"", UFC)); + transport.responses.add(response(200, "\"two\"", "{")); + transport.responses.add(response(304, null, null)); + transport.responses.add(response(200, "\"three\"", UFC.replace("hello", "recovered"))); + final ConfigurationStore store = new ConfigurationStore(); + final CdnConfigurationSource source = source(store, transport, millis -> {}); + + assertTrue(source.pollOnce()); + final Object accepted = store.current(); + assertFalse(source.pollOnce()); + assertEquals(accepted, store.current()); + assertTrue(source.pollOnce()); + assertEquals("\"one\"", transport.requestHeaders.get(2).get("If-None-Match")); + assertTrue(source.pollOnce()); + assertEquals("\"one\"", transport.requestHeaders.get(3).get("If-None-Match")); + assertEquals("recovered", store.current().flags.get("message").variations.get("on").value); + } + + @Test + void retriesTimeoutAndServerError() { + final QueueTransport transport = new QueueTransport(); + transport.failures.add(new IOException("timeout")); + transport.responses.add(response(503, null, null)); + transport.responses.add(response(200, null, UFC)); + final List delays = new ArrayList<>(); + final CdnConfigurationSource source = source(new ConfigurationStore(), transport, delays::add); + + assertTrue(source.pollOnce()); + assertEquals(2, delays.size()); + assertEquals(3, transport.requestHeaders.size()); + } + + @Test + void preventsOverlappingPollsAndCancelsOnClose() throws Exception { + final BlockingTransport transport = new BlockingTransport(); + final ConfigurationStore store = new ConfigurationStore(); + final CdnConfigurationSource source = source(store, transport, millis -> {}); + final Thread first = new Thread(source::pollOnce); + first.start(); + assertTrue(transport.entered.await(1, TimeUnit.SECONDS)); + + assertFalse(source.pollOnce()); + source.close(); + transport.release.countDown(); + first.join(1_000); + + assertTrue(transport.cancelled.get()); + assertEquals(SourceStatus.CLOSED, source.status()); + assertFalse(store.hasConfiguration()); + } + + @Test + void startPollsImmediatelyAndCloseCancelsSchedule() throws Exception { + final QueueTransport transport = new QueueTransport(); + transport.responses.add(response(200, null, UFC)); + final ConfigurationStore store = new ConfigurationStore(); + final CountDownLatch configured = new CountDownLatch(1); + store.addListener(ignored -> configured.countDown()); + final CdnConfigurationSource source = source(store, transport, millis -> {}); + + source.start(); + source.start(); + assertTrue(configured.await(1, TimeUnit.SECONDS)); + assertEquals(SourceStatus.READY, source.status()); + assertEquals(1, transport.requestHeaders.size()); + + source.close(); + source.close(); + assertTrue(transport.cancelled.get()); + assertFalse(source.pollOnce()); + source.start(); + assertEquals(1, transport.requestHeaders.size()); + } + + @Test + void startDoesNotBlockOnInitialPoll() throws Exception { + final BlockingTransport transport = new BlockingTransport(); + final CdnConfigurationSource source = source(new ConfigurationStore(), transport, millis -> {}); + final CountDownLatch returned = new CountDownLatch(1); + final Thread start = + new Thread( + () -> { + source.start(); + returned.countDown(); + }); + + start.start(); + assertTrue(transport.entered.await(1, TimeUnit.SECONDS)); + assertTrue(returned.await(1, TimeUnit.SECONDS)); + assertEquals(SourceStatus.STARTING, source.status()); + + source.close(); + transport.release.countDown(); + start.join(1_000); + } + + @Test + void doesNotRetryPermanentFailures() { + final QueueTransport transport = new QueueTransport(); + transport.responses.add(response(401, null, null)); + final CdnConfigurationSource source = source(new ConfigurationStore(), transport, millis -> {}); + + assertFalse(source.pollOnce()); + assertEquals(1, transport.requestHeaders.size()); + } + + @Test + void calculatesBoundedRetryDelays() { + assertEquals(5_000, CdnConfigurationSource.retryDelayMillis(30_000, 1, 1)); + assertEquals(10_000, CdnConfigurationSource.retryDelayMillis(600_000, 1, 1)); + assertEquals(5_000, CdnConfigurationSource.retryDelayMillis(3_000, 2, 1)); + assertEquals(30_000, CdnConfigurationSource.retryDelayMillis(600_000, 2, 1)); + } + + private static CdnConfigurationSource source( + final ConfigurationStore store, + final CdnConfigurationSource.Transport transport, + final CdnConfigurationSource.Sleeper sleeper) { + final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + final HttpConfigurationOptions options = + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test/config")) + .pollInterval(Duration.ofHours(1)) + .requestTimeout(Duration.ofSeconds(1)) + .build(); + return new CdnConfigurationSource(options, store, transport, executor, sleeper, () -> 1); + } + + private static CdnConfigurationSource.TransportResponse response( + final int status, final String etag, final String body) { + return new CdnConfigurationSource.TransportResponse( + status, etag, body == null ? null : body.getBytes(UTF_8)); + } + + private static class QueueTransport implements CdnConfigurationSource.Transport { + final Queue responses = new ArrayDeque<>(); + final Queue failures = new ArrayDeque<>(); + final List> requestHeaders = new ArrayList<>(); + final AtomicBoolean cancelled = new AtomicBoolean(); + + @Override + public CdnConfigurationSource.TransportResponse fetch( + final HttpConfigurationOptions options, final Map headers) + throws IOException { + requestHeaders.add(headers); + final IOException failure = failures.poll(); + if (failure != null) { + throw failure; + } + return responses.remove(); + } + + @Override + public void cancel() { + cancelled.set(true); + } + } + + private static final class BlockingTransport extends QueueTransport { + final CountDownLatch entered = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + + @Override + public CdnConfigurationSource.TransportResponse fetch( + final HttpConfigurationOptions options, final Map headers) + throws IOException { + requestHeaders.add(headers); + entered.countDown(); + try { + release.await(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + return response(200, null, UFC); + } + } + + private static final String UFC = + "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{\"key\":\"a\",\"rules\":[],\"splits\":[" + + "{\"variationKey\":\"on\",\"shards\":[]}]}]}}}"; +} diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnEndpointResolverTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnEndpointResolverTest.java new file mode 100644 index 00000000000..2403e060fbd --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnEndpointResolverTest.java @@ -0,0 +1,47 @@ +package datadog.openfeature.internal.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import org.junit.jupiter.api.Test; + +class CdnEndpointResolverTest { + + @Test + void resolvesManagedEndpointWithEnvironment() { + assertEquals( + URI.create( + "https://ufc-server.ff-cdn.datadoghq.eu/api/v2/feature-flagging/config/rules-based/server?dd_env=production"), + CdnEndpointResolver.resolve(null, "datadoghq.eu", "production")); + assertEquals( + URI.create( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server"), + CdnEndpointResolver.resolve(null, null, null)); + } + + @Test + void resolvesCustomBaseUrlAndKeepsExplicitPath() { + assertEquals( + URI.create("http://localhost:8080/api/v2/feature-flagging/config/rules-based/server"), + CdnEndpointResolver.resolve("http://localhost:8080/", "ignored", "ignored")); + assertEquals( + URI.create( + "https://example.test/api/v2/feature-flagging/config/rules-based/server?token=a%2Fb"), + CdnEndpointResolver.resolve("https://example.test/?token=a%2Fb", "ignored", "ignored")); + assertEquals( + URI.create("https://example.test/custom?query=true"), + CdnEndpointResolver.resolve( + " https://example.test/custom?query=true ", "ignored", "ignored")); + } + + @Test + void rejectsRelativeCustomUrlAndInvalidManagedSite() { + assertThrows( + IllegalArgumentException.class, + () -> CdnEndpointResolver.resolve("/relative", "datadoghq.com", null)); + assertThrows( + IllegalArgumentException.class, + () -> CdnEndpointResolver.resolve(null, "invalid site", null)); + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/HttpConfigurationOptionsTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/HttpConfigurationOptionsTest.java new file mode 100644 index 00000000000..fdd1d8be71d --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/HttpConfigurationOptionsTest.java @@ -0,0 +1,53 @@ +package datadog.openfeature.internal.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class HttpConfigurationOptionsTest { + + @Test + void buildsImmutableOptionsAndDefaults() { + final HttpConfigurationOptions options = + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test/config")) + .apiKey("key") + .managedEndpoint(true) + .build(); + + assertEquals(Duration.ofSeconds(30), options.pollInterval); + assertEquals(Duration.ofSeconds(5), options.requestTimeout); + assertEquals("key", options.apiKey); + assertTrue(options.managedEndpoint); + } + + @Test + void validatesRequiredAndPositiveOptions() { + assertThrows(NullPointerException.class, () -> HttpConfigurationOptions.builder().build()); + assertThrows( + NullPointerException.class, + () -> + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test")) + .pollInterval(null) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test")) + .pollInterval(Duration.ZERO) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test")) + .requestTimeout(Duration.ofSeconds(-1)) + .build()); + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java new file mode 100644 index 00000000000..f705f009afd --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java @@ -0,0 +1,201 @@ +package datadog.openfeature.internal.http; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class Java11TransportTest { + + private HttpServer server; + + @AfterEach + void closeServer() { + if (server != null) { + server.stop(0); + } + } + + @Test + void sendsHeadersAndReadsResponse() throws Exception { + final AtomicReference apiKey = new AtomicReference<>(); + final AtomicReference etag = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + apiKey.set(exchange.getRequestHeaders().getFirst("DD-API-KEY")); + etag.set(exchange.getRequestHeaders().getFirst("If-None-Match")); + final byte[] body = "response".getBytes(UTF_8); + exchange.getResponseHeaders().add("ETag", "\"next\""); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + final CdnConfigurationSource.Java11Transport transport = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + + final CdnConfigurationSource.TransportResponse response = + transport.fetch(options(Duration.ofSeconds(1), true), Map.of("If-None-Match", "\"old\"")); + + assertEquals(200, response.status); + assertEquals("\"next\"", response.etag); + assertArrayEquals("response".getBytes(UTF_8), response.body); + assertEquals("secret", apiKey.get()); + assertEquals("\"old\"", etag.get()); + } + + @Test + void enforcesRequestTimeout() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + try { + Thread.sleep(250); + exchange.sendResponseHeaders(304, -1); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + final CdnConfigurationSource.Java11Transport transport = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + + assertThrows( + IOException.class, () -> transport.fetch(options(Duration.ofMillis(25), false), Map.of())); + } + + @Test + void requestsAndDecodesGzipResponses() throws Exception { + final AtomicReference acceptEncoding = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + acceptEncoding.set(exchange.getRequestHeaders().getFirst("Accept-Encoding")); + final byte[] body = gzip("compressed".getBytes(UTF_8)); + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + final CdnConfigurationSource.Java11Transport transport = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + + final CdnConfigurationSource.TransportResponse response = + transport.fetch(options(Duration.ofSeconds(1), false), Map.of()); + + assertEquals("gzip", acceptEncoding.get()); + assertArrayEquals("compressed".getBytes(UTF_8), response.body); + } + + @Test + void acceptsEmptyGzipNotModifiedResponses() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + exchange.sendResponseHeaders(304, -1); + exchange.close(); + }); + server.start(); + final CdnConfigurationSource.Java11Transport transport = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + + final CdnConfigurationSource.TransportResponse response = + transport.fetch(options(Duration.ofSeconds(1), false), Map.of()); + + assertEquals(304, response.status); + assertArrayEquals(new byte[0], response.body); + } + + @Test + void cancelsRequestsBeforeAndDuringFetch() throws Exception { + final CdnConfigurationSource.Java11Transport closed = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + closed.cancel(); + assertThrows( + InterruptedIOException.class, + () -> closed.fetch(options(Duration.ofSeconds(1), false), Map.of())); + + final CountDownLatch entered = new CountDownLatch(1); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + entered.countDown(); + try { + Thread.sleep(5_000); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + final CdnConfigurationSource.Java11Transport active = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + final AtomicReference failure = new AtomicReference<>(); + final Thread thread = + new Thread( + () -> { + try { + active.fetch(options(Duration.ofSeconds(10), false), Map.of()); + } catch (final Throwable error) { + failure.set(error); + } + }); + thread.start(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + active.cancel(); + thread.join(1_000); + + assertTrue(failure.get() instanceof InterruptedIOException, String.valueOf(failure.get())); + } + + private HttpConfigurationOptions options( + final Duration requestTimeout, final boolean managedEndpoint) { + final URI endpoint = + server == null + ? URI.create("https://example.test/config") + : URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + return HttpConfigurationOptions.builder() + .endpoint(endpoint) + .requestTimeout(requestTimeout) + .pollInterval(Duration.ofSeconds(30)) + .apiKey("secret") + .managedEndpoint(managedEndpoint) + .build(); + } + + private static byte[] gzip(final byte[] input) throws IOException { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { + gzip.write(input); + } + return output.toByteArray(); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 425e217e822..5ba5fa18003 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) + implementation(project(":products:feature-flagging:feature-flagging-core")) implementation(project(":utils:logging-utils")) api(project(":utils:queue-utils")) 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 index f4fc35cce30..3495714b406 100644 --- 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 @@ -8,6 +8,7 @@ import datadog.communication.http.HttpRetryPolicy; import datadog.communication.http.OkHttpUtils; import datadog.logging.RatelimitedLogger; +import datadog.openfeature.internal.core.UfcParser; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; @@ -38,6 +39,7 @@ final class AgentlessConfigurationSource implements ConfigurationSourceService { private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessConfigurationSource.class); + private static final UfcParser UFC_PARSER = new UfcParser(); private static final String DATADOG_UFC_RULES_BASED_SERVER_PATH = "/api/v2/feature-flagging/config/rules-based/server"; @@ -227,7 +229,7 @@ private boolean apply(final UfcHttpResponse response) { } final ServerConfiguration configuration; try { - configuration = JsonApiUfcResponseParser.INSTANCE.parse(response.body); + configuration = UFC_PARSER.parseJsonApi(response.body); } catch (final IOException | RuntimeException e) { LOGGER.debug("Feature Flagging HTTP configuration source returned malformed UFC payload", e); return false; 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/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java index ba5536601f7..238d53a9857 100644 --- 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 @@ -1,139 +1,21 @@ 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.openfeature.internal.core.UfcParser; import datadog.remoteconfig.ConfigurationDeserializer; -import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -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.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; +/** Adapts the shared UFC parser to the Remote Configuration callback contract. */ final class UniversalFlagConfigParser implements ConfigurationDeserializer { static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); - private static final Moshi MOSHI = - new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build(); - private static final JsonAdapter V1_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); + private final UfcParser parser = new UfcParser(); 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 { - return V1_ADAPTER.fromJson(reader); - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } - - 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) { - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException ignored) { - // A malformed flag must not prevent other flags in the same config from evaluating. - } - } - reader.endObject(); - return flags; - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class DateAdapter extends JsonAdapter { - - @Nullable - @Override - public Date fromJson(@Nonnull final JsonReader reader) throws IOException { - final String date = reader.nextString(); - if (date == null) { - return null; - } - try { - final Instant instant = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); - return Date.from(instant); - } catch (Exception e) { - // ignore wrongly set dates - return null; - } - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Date value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } + return parser.parse(content); } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java deleted file mode 100644 index a31d47889ba..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.datadog.featureflag; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.IOException; -import org.junit.jupiter.api.Test; - -class JsonApiUfcResponseParserTest { - - @Test - void parsesJsonApiMembersInAnyOrder() throws Exception { - final ServerConfiguration configuration = - parse( - "{" - + "\"meta\":{\"ignored\":true}," - + "\"data\":{" - + "\"attributes\":" - + emptyConfig() - + ",\"ignored\":true," - + "\"type\":\"universal-flag-configuration\"" - + "}" - + "}"); - - assertNotNull(configuration); - assertEquals("Test", configuration.environment.name); - assertTrue(configuration.flags.isEmpty()); - } - - @Test - void rejectsRawUfc() throws Exception { - assertNull(parse(emptyConfig())); - } - - @Test - void rejectsUnexpectedJsonApiType() throws Exception { - assertNull(parse("{\"data\":{\"type\":\"other-type\",\"attributes\":" + emptyConfig() + "}}")); - } - - @Test - void rejectsNonStringJsonApiType() throws Exception { - assertNull(parse("{\"data\":{\"type\":null,\"attributes\":" + emptyConfig() + "}}")); - } - - @Test - void rejectsConfigurationWithoutFlags() throws Exception { - assertNull( - parse( - "{\"data\":{" - + "\"type\":\"universal-flag-configuration\"," - + "\"attributes\":{\"environment\":{\"name\":\"Test\"}}" - + "}}")); - } - - @Test - void rejectsNonObjectData() throws Exception { - assertNull(parse("{\"data\":[]}")); - } - - @Test - void rejectsTrailingJson() { - assertThrows( - IOException.class, - () -> - parse( - "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" - + emptyConfig() - + "}}{}")); - } - - private static ServerConfiguration parse(final String json) throws Exception { - return JsonApiUfcResponseParser.INSTANCE.parse(json.getBytes(UTF_8)); - } - - private static String emptyConfig() { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index c1f5ef17b87..0f18a665971 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,9 +1,6 @@ package com.datadog.featureflag; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Collections.emptyMap; -import static java.util.Collections.emptySet; -import static java.util.Collections.singleton; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -16,11 +13,6 @@ 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; @@ -29,14 +21,8 @@ import datadog.remoteconfig.Product; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.util.Date; -import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -44,7 +30,6 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.tabletest.junit.TableTest; @ExtendWith(MockitoExtension.class) class RemoteConfigServiceImplTest { @@ -189,23 +174,6 @@ void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { 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 allowsNullFlagMap() throws Exception { final ServerConfiguration config = @@ -252,62 +220,6 @@ void skipsNullFlagAndKeepsValidFlag() throws Exception { 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())); - } - - @TableTest({ - "scenario | value | expectedEpochMilli", - "utc second | '2023-01-01T00:00:00Z' | 1672531200000 ", - "utc end of year | '2023-12-31T23:59:59Z' | 1704067199000 ", - "leap day | '2024-02-29T12:00:00Z' | 1709208000000 ", - "millisecond precision | '2023-01-01T00:00:00.000Z' | 1672531200000 ", - "three fractional digits | '2023-06-15T14:30:45.123Z' | 1686839445123 ", - "six fractional digits truncate to millis | '2023-06-15T14:30:45.123456Z' | 1686839445123 ", - "six fractional digits preserve millis | '2023-06-15T14:30:45.235982Z' | 1686839445235 ", - "nine fractional digits truncate to millis | '2023-06-15T14:30:45.123456789Z' | 1686839445123 ", - "one fractional digit | '2023-06-15T14:30:45.1Z' | 1686839445100 ", - "two fractional digits | '2023-06-15T14:30:45.12Z' | 1686839445120 ", - "positive offset | '2023-01-01T01:00:00+01:00' | 1672531200000 ", - "negative offset | '2023-01-01T00:00:00-05:00' | 1672549200000 ", - "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 testDateParsing(final String value, final Long expectedEpochMilli) throws Exception { - final JsonReader reader = mock(JsonReader.class); - when(reader.nextString()).thenReturn(value); - final UniversalFlagConfigParser.DateAdapter adapter = - new UniversalFlagConfigParser.DateAdapter(); - - final Date parsed = adapter.fromJson(reader); - if (expectedEpochMilli == null) { - assertNull(parsed); - } else { - assertNotNull(parsed); - assertEquals(Instant.ofEpochMilli(expectedEpochMilli), parsed.toInstant()); - } - } - - @Test - void testParsingOnlyAdapter() { - final UniversalFlagConfigParser.DateAdapter adapter = - new UniversalFlagConfigParser.DateAdapter(); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), new Date())); - } - @SuppressWarnings("unchecked") private ConfigurationDeserializer deserializer() { return deserializerCaptor.getValue(); @@ -317,10 +229,6 @@ private static ServerConfiguration deserialize(final String json) throws Excepti return UniversalFlagConfigParser.INSTANCE.deserialize(json.getBytes(UTF_8)); } - private static Moshi moshi() { - return new Moshi.Builder().add(Date.class, new UniversalFlagConfigParser.DateAdapter()).build(); - } - private static String emptyConfig() { return "{" + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," diff --git a/settings.gradle.kts b/settings.gradle.kts index c2ef7c17958..cc9ca3d886d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -162,6 +162,8 @@ include( ":products:feature-flagging:feature-flagging-api", ":products:feature-flagging:feature-flagging-bootstrap", ":products:feature-flagging:feature-flagging-config", + ":products:feature-flagging:feature-flagging-core", + ":products:feature-flagging:feature-flagging-http", ":products:feature-flagging:feature-flagging-lib" )