diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index da9f400e14b..4f2cb4e1fc1 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -1,5 +1,13 @@ package com.datadog.openfeature; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.ALLOCATION_KEY; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EVALUATION_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.HAS_EVALUATION_FEATURES; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.OBSERVE_FULL_EVALUATION_DATA; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; + import com.datadog.openfeature.internal.FeatureFlagsRuntime; import com.datadog.openfeature.internal.bridge.v1.AgentBridge; import com.datadog.openfeature.internal.config.Settings; @@ -8,10 +16,13 @@ import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.exposure.Subject; +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.ValueType; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Structure; @@ -81,6 +92,13 @@ class DDEvaluator implements Evaluator, Consumer { private final Settings settings; private final FlagEvaluator core; private final ConfigurationStore configuration = new ConfigurationStore(); + // Dependency telemetry belongs to one OpenFeature hook lifecycle. The exposure hook opens a + // scope before provider evaluation and closes it in finallyAfter. Evaluation hooks inspect the + // staged records before that close, so dependency and root telemetry share the same opt-out and + // application-hook boundary. Direct provider calls have no scope and emit no telemetry. A stack + // keeps nested evaluations on the same thread isolated. + private final ThreadLocal> dependencyTelemetryScopes = + new ThreadLocal<>(); private volatile FeatureFlagsRuntime runtime; DDEvaluator(final Runnable configCallback, final AgentBridge bridge, final Settings settings) { @@ -125,6 +143,7 @@ public boolean hasConfiguration() { @Override public synchronized void shutdown() { + dependencyTelemetryScopes.remove(); final FeatureFlagsRuntime released = this.runtime; this.runtime = null; if (released != null) { @@ -157,7 +176,11 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { - return core.evaluate(configuration.get(), target, key, defaultValue, context); + final FlagEvaluator.EvaluationResult evaluation = + core.evaluateWithTrace(configuration.get(), target, key, defaultValue, context); + final ProviderEvaluation result = evaluation.details; + stageDependencyTelemetry(key, context, evaluation); + return result; } static boolean isAllocationActive(final Allocation allocation, final Instant now) { @@ -242,6 +265,247 @@ void recordExposure(final ExposureEvent event) { } } + void publishDependencyEvaluation( + final FlagEvaluator.DependencyEvaluation dependency, + final EvaluationContext context, + final long evaluationTimestampMs, + final FlagEvaluationWriter writer) { + final ImmutableMetadata metadata = dependency.metadata; + final Map values = + metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); + final Object allocationValue = values.get(ALLOCATION_KEY); + final String allocationKey = + allocationValue instanceof String ? (String) allocationValue : null; + if (writer == null || !writer.hasCapacityForEnqueue()) { + if (writer != null) { + writer.countPreQueueOverflow(); + } + return; + } + final boolean observeFullEvaluationData = + Boolean.TRUE.equals(values.get(OBSERVE_FULL_EVALUATION_DATA)); + String errorMessage = observeFullEvaluationData ? dependency.errorMessage : null; + if ((errorMessage == null || errorMessage.isEmpty()) && dependency.errorCode != null) { + errorMessage = dependency.errorCode.name(); + } + final Map attrs; + if (observeFullEvaluationData) { + final CopyResult copy = copyPrunedContext(context); + if (copy.truncatedReason != null) { + writer.countContextTruncated(copy.truncatedReason); + } + attrs = copy.attrs; + } else { + attrs = Collections.emptyMap(); + } + final Map features = + Boolean.TRUE.equals(values.get(HAS_EVALUATION_FEATURES)) + ? SplitFeatureMetadata.featuresWithPrefix(metadata, EVALUATION_FEATURE_PREFIX) + : Collections.emptyMap(); + writer.enqueue( + new FlagEvalEvent( + dependency.flagKey, + dependency.variant, + allocationKey, + context == null ? null : context.getTargetingKey(), + errorMessage, + evaluationTimestampMs, + observeFullEvaluationData, + attrs, + features)); + } + + void publishDependencyEvaluations( + final DependencyEvaluationTrace trace, + final EvaluationContext context, + final FlagEvaluationWriter writer) { + for (final FlagEvaluator.DependencyEvaluation dependency : trace.evaluations) { + try { + publishDependencyEvaluation(dependency, context, trace.evaluationTimestampMs, writer); + } catch (final RuntimeException | LinkageError ignored) { + // One telemetry record must not suppress the root or later dependency records. + } + } + } + + void recordDependencyMetrics( + final DependencyEvaluationTrace trace, final FlagEvalMetrics metrics) { + if (metrics == null) { + return; + } + for (final FlagEvaluator.DependencyEvaluation dependency : trace.evaluations) { + try { + final ImmutableMetadata metadata = dependency.metadata; + final Map values = + metadata == null + ? Collections.emptyMap() + : metadata.asUnmodifiableMap(); + final Object allocationValue = values.get(ALLOCATION_KEY); + metrics.record( + dependency.flagKey, + dependency.variant, + dependency.reason, + dependency.errorCode, + allocationValue instanceof String ? (String) allocationValue : null); + } catch (final RuntimeException | LinkageError ignored) { + // Metrics are best effort and must not affect evaluation or the root metric. + } + } + } + + void beginDependencyExposureScope( + final String rootFlagKey, final EvaluationContext evaluationContext) { + Deque scopes = dependencyTelemetryScopes.get(); + if (scopes == null) { + scopes = new LinkedList<>(); + dependencyTelemetryScopes.set(scopes); + } + scopes.push(new DependencyTelemetryScope(rootFlagKey, evaluationContext)); + } + + List endDependencyExposureScope() { + final Deque scopes = dependencyTelemetryScopes.get(); + if (scopes == null || scopes.isEmpty()) { + return Collections.emptyList(); + } + final List exposures = scopes.pop().exposures; + if (scopes.isEmpty()) { + dependencyTelemetryScopes.remove(); + } + return exposures; + } + + DependencyEvaluationTrace dependencyEvaluationTrace( + final String rootFlagKey, final EvaluationContext evaluationContext) { + final Deque scopes = dependencyTelemetryScopes.get(); + if (scopes == null || scopes.isEmpty()) { + return DependencyEvaluationTrace.EMPTY; + } + final DependencyTelemetryScope scope = scopes.peek(); + return scope.sealed && scope.matches(rootFlagKey, evaluationContext) + ? scope.evaluationTrace + : DependencyEvaluationTrace.EMPTY; + } + + private void stageDependencyTelemetry( + final String rootFlagKey, + final EvaluationContext evaluationContext, + final FlagEvaluator.EvaluationResult evaluation) { + final Deque scopes = dependencyTelemetryScopes.get(); + if (scopes == null || scopes.isEmpty()) { + return; + } + final DependencyTelemetryScope scope = scopes.peek(); + if (scope.sealed || !scope.matches(rootFlagKey, evaluationContext)) { + return; + } + // The first matching provider evaluation owns this hook lifecycle, even when it has no + // eligible candidates. This prevents a later direct or reentrant evaluation from filling the + // scope before finallyAfter consumes it. + scope.sealed = true; + scope.evaluationTrace = + new DependencyEvaluationTrace( + evaluation.dependencyEvaluations, evaluation.evaluationTimestampMs); + final List candidates = + evaluation.details.getErrorCode() == null + ? evaluation.dependencyExposures + : Collections.emptyList(); + if (candidates == null || candidates.isEmpty()) { + return; + } + List eligible = null; + for (final FlagEvaluator.DependencyExposure candidate : candidates) { + final ImmutableMetadata metadata = candidate.metadata; + final Map values = + metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); + if (!Boolean.TRUE.equals(values.get(DO_LOG)) + || !(values.get(ALLOCATION_KEY) instanceof String) + || candidate.variant == null) { + continue; + } + if (eligible == null) { + eligible = new ArrayList<>(); + } + eligible.add(candidate); + } + if (eligible != null) { + scope.exposures = Collections.unmodifiableList(eligible); + } + } + + void recordDependencyExposures( + final List exposures, final EvaluationContext context) { + final ExposureDeduplicationCache cache = exposureCache(); + if (cache == null || context == null) { + return; + } + for (final FlagEvaluator.DependencyExposure exposure : exposures) { + final ImmutableMetadata metadata = exposure.metadata; + final Map values = + metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); + if (!Boolean.TRUE.equals(values.get(DO_LOG))) { + continue; + } + final Object allocationValue = values.get(ALLOCATION_KEY); + final String allocationKey = + allocationValue instanceof String ? (String) allocationValue : null; + final String variantKey = exposure.variant; + if (allocationKey == null || variantKey == null) { + continue; + } + final Object serialValue = values.get(SPLIT_SERIAL_ID); + final Integer serialId = serialValue instanceof Integer ? (Integer) serialValue : null; + if (cache.recordIfChanged( + exposure.flagKey, context.getTargetingKey(), allocationKey, variantKey, serialId) + && isExposureDeliveryEnabled()) { + recordExposure( + exposureEvent( + exposure.flagKey, + allocationKey, + variantKey, + context, + serialId, + SplitFeatureMetadata.featuresWithPrefix(metadata, EXPOSURE_FEATURE_PREFIX))); + } + } + } + + static final class DependencyEvaluationTrace { + private static final DependencyEvaluationTrace EMPTY = + new DependencyEvaluationTrace(Collections.emptyList(), 0); + + final List evaluations; + final long evaluationTimestampMs; + + private DependencyEvaluationTrace( + final List evaluations, + final long evaluationTimestampMs) { + this.evaluations = evaluations; + this.evaluationTimestampMs = evaluationTimestampMs; + } + } + + private static final class DependencyTelemetryScope { + private final String rootFlagKey; + private final EvaluationContext evaluationContext; + private DependencyEvaluationTrace evaluationTrace = DependencyEvaluationTrace.EMPTY; + private List exposures = Collections.emptyList(); + private boolean sealed; + + private DependencyTelemetryScope( + final String rootFlagKey, final EvaluationContext evaluationContext) { + this.rootFlagKey = rootFlagKey; + this.evaluationContext = evaluationContext; + } + + private boolean matches( + final String evaluatedFlagKey, final EvaluationContext evaluatedContext) { + return rootFlagKey != null + && rootFlagKey.equals(evaluatedFlagKey) + && evaluationContext == evaluatedContext; + } + } + static AbstractMap flattenContext(final EvaluationContext context) { return flattenValues(snapshotValues(context)); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java index db6eba8a70d..3f2ba6f2b34 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -12,7 +12,11 @@ import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Reason; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; import org.slf4j.Logger; @@ -35,21 +39,69 @@ class ExposureLoggingHook implements Hook { private final Supplier evaluatorSupplier; private final AtomicBoolean warned = new AtomicBoolean(); + private final ThreadLocal> evaluatorScopes = new ThreadLocal<>(); ExposureLoggingHook(final Supplier evaluatorSupplier) { this.evaluatorSupplier = evaluatorSupplier; } + @Override + public Optional before( + final HookContext ctx, final Map hints) { + DDEvaluator evaluator = null; + try { + evaluator = this.evaluatorSupplier.get(); + if (evaluator != null) { + evaluator.beginDependencyExposureScope( + ctx == null ? null : ctx.getFlagKey(), ctx == null ? null : ctx.getCtx()); + } + } catch (final RuntimeException | LinkageError e) { + warnOnce(e); + evaluator = null; + } + List scopes = evaluatorScopes.get(); + if (scopes == null) { + scopes = new ArrayList<>(); + evaluatorScopes.set(scopes); + } + scopes.add(new ExposureScope(ctx, evaluator)); + return Optional.empty(); + } + @Override public void finallyAfter( final HookContext ctx, final FlagEvaluationDetails details, final Map hints) { try { - if (details == null || details.getErrorCode() != null) { + final DDEvaluator scopedEvaluator = popEvaluatorScope(ctx); + final List + dependencyExposures = + scopedEvaluator == null + ? java.util.Collections + . + emptyList() + : scopedEvaluator.endDependencyExposureScope(); + if (details == null) { + log.trace("Feature Flagging exposure suppressed: evaluation failed or details unavailable"); + return; + } + final DDEvaluator evaluator = + scopedEvaluator == null ? this.evaluatorSupplier.get() : scopedEvaluator; + if (details.getErrorCode() != null || Reason.ERROR.name().equals(details.getReason())) { log.trace("Feature Flagging exposure suppressed: evaluation failed or details unavailable"); return; } + final EvaluationContext context = ctx == null ? null : ctx.getCtx(); + if (evaluator != null) { + try { + evaluator.recordDependencyExposures(dependencyExposures, context); + } catch (final RuntimeException | LinkageError e) { + // Dependency exposure delivery is best-effort and must not suppress the root exposure. + warnOnce(e); + } + } final ImmutableMetadata metadata = details.getFlagMetadata(); if (metadata == null) { log.trace("Feature Flagging exposure suppressed: metadata unavailable"); @@ -69,13 +121,11 @@ public void finallyAfter( final String allocationKey = allocationValue instanceof String ? (String) allocationValue : null; final String variantKey = details.getVariant(); - final EvaluationContext context = ctx == null ? null : ctx.getCtx(); if (allocationKey == null || variantKey == null || context == null) { log.trace( "Feature Flagging exposure suppressed: allocation, variant, or context unavailable"); return; } - final DDEvaluator evaluator = this.evaluatorSupplier.get(); final ExposureDeduplicationCache cache = evaluator == null ? null : evaluator.exposureCache(); if (cache == null) { log.trace("Feature Flagging exposure suppressed: provider not initialized"); @@ -103,9 +153,41 @@ public void finallyAfter( } } catch (final RuntimeException | LinkageError e) { // Never let exposure reporting break flag evaluation. - if (this.warned.compareAndSet(false, true)) { - log.warn("Could not record a feature flag exposure; later failures are not logged", e); - } + warnOnce(e); + } + } + + private DDEvaluator popEvaluatorScope(final HookContext ctx) { + final List scopes = evaluatorScopes.get(); + if (scopes == null || scopes.isEmpty()) { + return null; + } + final ExposureScope scope = scopes.get(scopes.size() - 1); + // The SDK invokes finally hooks even for hooks whose before method was skipped because an + // earlier before hook threw. Do not let that unmatched lifecycle pop an enclosing evaluation. + if (scope.context != ctx) { + return null; + } + scopes.remove(scopes.size() - 1); + if (scopes.isEmpty()) { + evaluatorScopes.remove(); + } + return scope.evaluator; + } + + private void warnOnce(final Throwable failure) { + if (this.warned.compareAndSet(false, true)) { + log.warn("Could not record a feature flag exposure; later failures are not logged", failure); + } + } + + private static final class ExposureScope { + private final HookContext context; + private final DDEvaluator evaluator; + + private ExposureScope(final HookContext context, final DDEvaluator evaluator) { + this.context = context; + this.evaluator = evaluator; } } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java index 504ce4a3337..3ba4a674826 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -41,8 +41,17 @@ class FlagEvalLoggingHook implements Hook { /** Pipeline resolver, returning {@code null} when evaluation counts are not delivered. */ private final Supplier writerSupplier; + private final Supplier evaluatorSupplier; + FlagEvalLoggingHook(final Supplier writerSupplier) { + this(writerSupplier, () -> null); + } + + FlagEvalLoggingHook( + final Supplier writerSupplier, + final Supplier evaluatorSupplier) { this.writerSupplier = writerSupplier; + this.evaluatorSupplier = evaluatorSupplier; } /** @@ -66,6 +75,16 @@ public void finallyAfter( return; } + final DDEvaluator evaluator = evaluatorSupplier.get(); + if (evaluator != null && ctx != null) { + try { + evaluator.publishDependencyEvaluations( + evaluator.dependencyEvaluationTrace(ctx.getFlagKey(), ctx.getCtx()), ctx.getCtx(), w); + } catch (final RuntimeException | LinkageError ignored) { + // Dependency telemetry is best effort and must not suppress the root event. + } + } + // Pre-queue guard: if the queue is already saturated, avoid the context-copy work whose // result would be discarded on offer(). Best-effort — the queue can still be full at // offer() time — but flips a full snapshot into an O(1) check on the drop path. diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java index d63cbe56d8c..c8fb377fb27 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java @@ -6,13 +6,21 @@ import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.ImmutableMetadata; import java.util.Map; +import java.util.function.Supplier; class FlagEvalMetricsHook implements Hook { private final FlagEvalMetrics metrics; + private final Supplier evaluatorSupplier; FlagEvalMetricsHook(FlagEvalMetrics metrics) { + this(metrics, () -> null); + } + + FlagEvalMetricsHook( + final FlagEvalMetrics metrics, final Supplier evaluatorSupplier) { this.metrics = metrics; + this.evaluatorSupplier = evaluatorSupplier; } @Override @@ -21,6 +29,15 @@ public void finallyAfter( if (metrics == null || details == null) { return; } + try { + final DDEvaluator evaluator = evaluatorSupplier.get(); + if (evaluator != null && ctx != null) { + evaluator.recordDependencyMetrics( + evaluator.dependencyEvaluationTrace(ctx.getFlagKey(), ctx.getCtx()), metrics); + } + } catch (final RuntimeException | LinkageError ignored) { + // Dependency metrics are best effort and must not suppress the root metric. + } try { String flagKey = details.getFlagKey(); String variant = details.getVariant(); @@ -34,7 +51,7 @@ public void finallyAfter( } metrics.record(flagKey, variant, reason, errorCode, allocationKey); - } catch (Exception e) { + } catch (final RuntimeException | LinkageError ignored) { // Never let metrics recording break flag evaluation } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java index 543aa018663..681f2ba20d4 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -47,6 +47,7 @@ public class Provider extends EventProvider implements Metadata { new AtomicReference<>(InitializationState.NOT_STARTED); private final FlagEvalMetrics flagEvalMetrics; private final FlagEvalMetricsHook flagEvalMetricsHook; + private final boolean evaluationTelemetryOptOutEnabled; // Span enrichment: null unless the gate is on, so the feature has no idle overhead when off. private final SpanEnrichmentHook spanEnrichmentHook; // Precomputed hook list returned by getProviderHooks() on every evaluation. Immutable and built @@ -91,14 +92,15 @@ public Provider(final Options options) { FlagEvalMetricsHook hook = null; try { metrics = new FlagEvalMetrics(); - hook = new FlagEvalMetricsHook(metrics); + hook = new FlagEvalMetricsHook(metrics, this::datadogEvaluator); } catch (LinkageError | Exception e) { // This outer catch fires when the metrics helper itself can't load (OTel API absent). log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); } this.flagEvalMetrics = metrics; this.flagEvalMetricsHook = hook; - + this.evaluationTelemetryOptOutEnabled = + options.isEvaluationTelemetryOptOutParameterEnabled(this.settings); // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle // per-evaluation overhead. final boolean spanEnrichmentEnabled = @@ -116,11 +118,11 @@ public Provider(final Options options) { } // EVP flagevaluation hook: always registered; no-op when the pipeline is absent (killswitch // off or provider not initialized). The pipeline is resolved lazily on each call. - hooks.add(new FlagEvalLoggingHook<>(this::flagEvaluationPipeline)); + hooks.add(new FlagEvalLoggingHook<>(this::flagEvaluationPipeline, this::datadogEvaluator)); if (spanEnrichmentHook != null) { hooks.add(spanEnrichmentHook); } - if (options.isEvaluationTelemetryOptOutParameterEnabled(this.settings)) { + if (this.evaluationTelemetryOptOutEnabled) { for (int i = 0; i < hooks.size(); i++) { hooks.set(i, new EvaluationTelemetryOptOutHook(hooks.get(i))); } @@ -293,8 +295,13 @@ private Evaluator buildEvaluator() { if (evaluator != null) { return evaluator; } - return new DDEvaluator( - this::onConfigurationChange, this.bridge, this.settings, this.experimentationOptOutMode); + final DDEvaluator built = + new DDEvaluator( + this::onConfigurationChange, + this.bridge, + this.settings, + this.experimentationOptOutMode); + return built; } private FlagEvaluationWriter flagEvaluationPipeline() { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java index 7eed50bb8d2..10b0504ca2c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -35,6 +35,8 @@ public final class CdnConfigurationSource implements ConfigurationService { static final String UFC_RULES_BASED_SERVER_PATH = "/api/v2/feature-flagging/config/rules-based/server"; + static final String FEATURE_FLAGS_CAPABILITIES_HEADER = "X-DD-FEATURE-FLAGS-CAPABILITIES"; + static final String DEPENDENT_FLAG_CAPABILITY = "dependent-flag-variant-key-v1"; static final int MAX_ATTEMPTS = 3; private static final long FIRST_RETRY_MIN_MILLIS = 2_000; private static final long FIRST_RETRY_MAX_MILLIS = 10_000; @@ -296,7 +298,8 @@ private HttpRequest buildRequest() { final HttpRequest.Builder request = HttpRequest.newBuilder(this.endpoint) .timeout(this.requestTimeout) - .header("Accept-Encoding", "gzip"); + .header("Accept-Encoding", "gzip") + .header(FEATURE_FLAGS_CAPABILITIES_HEADER, DEPENDENT_FLAG_CAPABILITY); Headers.addMetadata(request); if (this.apiKey != null) { request.header("DD-API-KEY", this.apiKey); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java index 03cdfe3b9de..3f810768fde 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -11,6 +11,8 @@ import static com.datadog.openfeature.internal.evaluation.MetadataKeys.OBSERVE_FULL_EVALUATION_DATA; import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; import com.datadog.openfeature.ExperimentationOptOutMode; import com.datadog.openfeature.internal.ufc.Allocation; @@ -36,8 +38,11 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -54,6 +59,7 @@ public final class FlagEvaluator { static final String FEATURE_DESTINATION_HOOK = "HOOK"; static final String FEATURE_DESTINATION_EXPOSURE = "EXPOSURE"; static final String FEATURE_DESTINATION_EVALUATION = "EVALUATION"; + static final int DEFAULT_MAX_DEPENDENCY_DEPTH = 10; // A newer UFC can name destinations this SDK does not know; warn once per name, not per // evaluation. static final int MAX_WARNED_FEATURE_DESTINATIONS = 64; @@ -87,6 +93,16 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { + return evaluateWithTrace(config, target, key, defaultValue, context).details; + } + + /** Evaluates a root flag and returns all telemetry candidates produced by its graph. */ + public EvaluationResult evaluateWithTrace( + final ServerConfiguration config, + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { // Snapshot the config once and thread observeFullEvaluationData through every // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's // config and cannot drift on a concurrent Remote Config swap. @@ -94,143 +110,191 @@ public ProviderEvaluation evaluate( // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. final boolean observeFullEvaluationData = config != null && Boolean.TRUE.equals(config.observeFullEvaluationData); + final long evaluationTimestampMs = System.currentTimeMillis(); + if (config == null) { + return new EvaluationState( + null, context, observeFullEvaluationData, evaluationTimestampMs, null) + .complete( + error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData), + false); + } + if (context == null) { + return new EvaluationState( + config, null, observeFullEvaluationData, evaluationTimestampMs, null) + .complete( + error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData), + false); + } + final Instant experimentationCutoff; try { - if (config == null) { - return error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData); - } - - if (context == null) { - return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); - } - - final Instant experimentationCutoff; - try { - experimentationCutoff = ExperimentationOptOut.cutoff(experimentationOptOutMode, context); - } catch (final ExperimentationOptOut.InvalidTimestampException e) { - // This diagnostic is fixed SDK text. It contains no customer context values. - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.INVALID_CONTEXT) - .errorMessage(ExperimentationOptOut.INVALID_TIMESTAMP) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } - - final Flag flag = config.flags.get(key); - if (flag == null) { - if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { - return error( - defaultValue, - ErrorCode.PARSE_ERROR, - "invalid configuration for flag " + key, - observeFullEvaluationData); - } - return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); - } + experimentationCutoff = ExperimentationOptOut.cutoff(experimentationOptOutMode, context); + } catch (final ExperimentationOptOut.InvalidTimestampException e) { + // This diagnostic is fixed SDK text. It contains no customer context values. + final ProviderEvaluation invalidContext = + ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.INVALID_CONTEXT) + .errorMessage(ExperimentationOptOut.INVALID_TIMESTAMP) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + return new EvaluationState( + config, context, observeFullEvaluationData, evaluationTimestampMs, null) + .complete(invalidContext, false); + } catch (final Exception e) { + return new EvaluationState( + config, context, observeFullEvaluationData, evaluationTimestampMs, null) + .complete( + error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData), + false); + } + final EvaluationState state = + new EvaluationState( + config, + context, + observeFullEvaluationData, + evaluationTimestampMs, + experimentationCutoff); + ProviderEvaluation result; + boolean commitExposures; + try { + result = evaluateFlag(state, target, key, defaultValue); + commitExposures = result.getErrorCode() == null; + } catch (final DependencyGraphException e) { + result = error(defaultValue, e.errorCode, e.getMessage(), observeFullEvaluationData); + commitExposures = false; + } catch (final PatternSyntaxException e) { + result = + error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); + commitExposures = false; + } catch (final NumberFormatException e) { + result = + error(defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); + commitExposures = false; + } catch (final Exception e) { + result = error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); + commitExposures = false; + } + return state.complete(result, commitExposures); + } - if (experimentationCutoff != null - && experimentationOptOutMode == ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY - && ExperimentationOptOut.hasInvalidActivation(flag.allocations)) { + private ProviderEvaluation evaluateFlag( + final EvaluationState state, final Class target, final String key, final T defaultValue) { + final ServerConfiguration config = state.config; + if (config == null) { + return error( + defaultValue, ErrorCode.PROVIDER_NOT_READY, null, state.observeFullEvaluationData); + } + if (state.context == null) { + return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, state.observeFullEvaluationData); + } + final Flag flag = config.flags == null ? null : config.flags.get(key); + if (flag == null) { + if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { return error( defaultValue, ErrorCode.PARSE_ERROR, - "invalid randomizationActivatedAt for flag " + key, - observeFullEvaluationData); + "invalid configuration for flag " + key, + state.observeFullEvaluationData); } + return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, state.observeFullEvaluationData); + } + if (state.experimentationCutoff != null + && experimentationOptOutMode == ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY + && ExperimentationOptOut.hasInvalidActivation(flag.allocations)) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "invalid randomizationActivatedAt for flag " + key, + state.observeFullEvaluationData); + } + if (!flag.enabled) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DISABLED.name()) + .flagMetadata(consentMetadata(state.observeFullEvaluationData)) + .build(); + } + if (flag.allocations == null) { + return error( + defaultValue, + ErrorCode.GENERAL, + "Missing allocations for flag " + key, + state.observeFullEvaluationData); + } - if (!flag.enabled) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DISABLED.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); + final Instant now = Instant.ofEpochMilli(state.evaluationTimestampMs); + final String targetingKey = state.context.getTargetingKey(); + for (final Allocation allocation : flag.allocations) { + if (state.experimentationCutoff != null + && ExperimentationOptOut.excludes( + experimentationOptOutMode, state.experimentationCutoff, allocation)) { + continue; } - - if (flag.allocations == null) { - return error( - defaultValue, - ErrorCode.GENERAL, - "Missing allocations for flag " + key, - observeFullEvaluationData); + if (!isAllocationActive(allocation, now)) { + continue; } - - final Instant now = Instant.now(); - final long evalTimestampMs = now.toEpochMilli(); - final String targetingKey = context.getTargetingKey(); - - for (final Allocation allocation : flag.allocations) { - if (experimentationCutoff != null - && ExperimentationOptOut.excludes( - experimentationOptOutMode, experimentationCutoff, allocation)) { - continue; - } - 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, + final ExposureCheckpoint allocationCheckpoint = state.checkpoint(); + if (!isEmpty(allocation.rules) + && !evaluateRules(allocation.rules, state, key, state.context)) { + state.rollback(allocationCheckpoint); + continue; + } + if (!isEmpty(allocation.splits)) { + for (final Split split : allocation.splits) { + boolean allShardsMatch = true; + if (!isEmpty(split.shards)) { + if (targetingKey == null) { + state.rollback(allocationCheckpoint); + return error( defaultValue, - flag, - split.variationKey, - allocation, - split, - context, - evalTimestampMs, - observeFullEvaluationData); - } else { - if (targetingKey == null) { - return error( - defaultValue, ErrorCode.TARGETING_KEY_MISSING, null, observeFullEvaluationData); - } - // To match a split, subject must match ALL underlying shards - boolean allShardsMatch = true; - for (final Shard shard : split.shards) { - if (!matchesShard(shard, targetingKey)) { - allShardsMatch = false; - break; - } - } - if (allShardsMatch) { - return resolveVariant( - target, - defaultValue, - flag, - split.variationKey, - allocation, - split, - context, - evalTimestampMs, - observeFullEvaluationData); + ErrorCode.TARGETING_KEY_MISSING, + null, + state.observeFullEvaluationData); + } + for (final Shard shard : split.shards) { + if (!matchesShard(shard, targetingKey)) { + allShardsMatch = false; + break; } } } + if (allShardsMatch) { + return resolveVariant( + target, + defaultValue, + flag, + split.variationKey, + allocation, + split, + state.context, + state.evaluationTimestampMs, + state.observeFullEvaluationData); + } } } + state.rollback(allocationCheckpoint); + } + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .flagMetadata(consentMetadata(state.observeFullEvaluationData)) + .build(); + } - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } catch (final PatternSyntaxException e) { - return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); - } catch (final NumberFormatException e) { - return error( - defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); - } catch (final Exception e) { - return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); + private static int maxDependencyDepth(final ServerConfiguration config) { + if (config.evaluatorParams == null || !config.evaluatorParams.maxDependencyDepthPresent) { + return DEFAULT_MAX_DEPENDENCY_DEPTH; } + final Object configured = config.evaluatorParams.maxDependencyDepth; + if (configured instanceof Number) { + final double value = ((Number) configured).doubleValue(); + if (Double.isFinite(value) && value == Math.rint(value) && value >= 0 && value <= 255) { + return (int) value; + } + } + throw new DependencyGraphException( + ErrorCode.PARSE_ERROR, "maxDependencyDepth must be an integer from 0 through 255"); } private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { @@ -276,15 +340,20 @@ public static boolean isAllocationActive(final Allocation allocation, final Inst return true; } - private static boolean evaluateRules(final List rules, final EvaluationContext context) { + private boolean evaluateRules( + final List rules, + final EvaluationState state, + final String rootFlagKey, + final EvaluationContext context) { for (final Rule rule : rules) { if (isEmpty(rule.conditions)) { continue; } + final ExposureCheckpoint ruleCheckpoint = state.checkpoint(); boolean allConditionsMatch = true; for (final ConditionConfiguration condition : rule.conditions) { - if (!evaluateCondition(condition, context)) { + if (!evaluateCondition(condition, state, rootFlagKey, context)) { allConditionsMatch = false; break; } @@ -293,21 +362,30 @@ private static boolean evaluateRules(final List rules, final EvaluationCon if (allConditionsMatch) { return true; } + state.rollback(ruleCheckpoint); } return false; } - private static boolean evaluateCondition( - final ConditionConfiguration condition, final EvaluationContext context) { + private boolean evaluateCondition( + final ConditionConfiguration condition, + final EvaluationState state, + final String rootFlagKey, + final EvaluationContext context) { + final Object attributeValue; + if (condition.flagEvaluationKey != null) { + attributeValue = + state.dependencySession(rootFlagKey).resolveCurrent(condition.flagEvaluationKey); + } else { + attributeValue = resolveAttribute(condition.attribute, context); + } if (condition.operator == ConditionOperator.IS_NULL) { - final Object value = resolveAttribute(condition.attribute, context); - boolean isNull = value == null; + boolean isNull = attributeValue == 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; } @@ -656,6 +734,368 @@ private static Double parseDouble(final Object value) { return Double.parseDouble(String.valueOf(value)); } + private final class EvaluationState { + private final ServerConfiguration config; + private final EvaluationContext context; + private final boolean observeFullEvaluationData; + private final long evaluationTimestampMs; + private final Instant experimentationCutoff; + private DependencyEvaluationSession session; + + private EvaluationState( + final ServerConfiguration config, + final EvaluationContext context, + final boolean observeFullEvaluationData, + final long evaluationTimestampMs, + final Instant experimentationCutoff) { + this.config = config; + this.context = context; + this.observeFullEvaluationData = observeFullEvaluationData; + this.evaluationTimestampMs = evaluationTimestampMs; + this.experimentationCutoff = experimentationCutoff; + } + + private DependencyEvaluationSession dependencySession(final String rootFlagKey) { + if (session == null) { + session = new DependencyEvaluationSession(rootFlagKey, maxDependencyDepth(config), this); + } + return session; + } + + private ExposureCheckpoint checkpoint() { + return session == null ? ExposureCheckpoint.EMPTY : session.checkpoint(); + } + + private void rollback(final ExposureCheckpoint checkpoint) { + if (session != null) { + session.rollback(checkpoint); + } + } + + private EvaluationResult complete( + final ProviderEvaluation details, final boolean commitExposures) { + return session == null + ? new EvaluationResult<>(details, emptyList(), emptyList(), evaluationTimestampMs) + : session.complete(details, commitExposures); + } + } + + private final class DependencyEvaluationSession { + private final int maxDependencyDepth; + private final EvaluationState state; + private final Map memo = new HashMap<>(); + private final Set active = new HashSet<>(); + private final List frames = new ArrayList<>(); + private final Set evaluatedFlags = new HashSet<>(); + private final List evaluations = new ArrayList<>(); + private final List exposures = new ArrayList<>(); + + private DependencyEvaluationSession( + final String rootFlagKey, final int maxDependencyDepth, final EvaluationState state) { + this.maxDependencyDepth = maxDependencyDepth; + this.state = state; + active.add(rootFlagKey); + } + + private ExposureCheckpoint checkpoint() { + return new ExposureCheckpoint( + exposures.size(), + frames.isEmpty() ? 0 : frames.get(frames.size() - 1).exposureTrace.size()); + } + + private void rollback(final ExposureCheckpoint checkpoint) { + while (exposures.size() > checkpoint.exposureCount) { + exposures.remove(exposures.size() - 1); + } + if (!frames.isEmpty()) { + final DependencyFrame frame = frames.get(frames.size() - 1); + while (frame.exposureTrace.size() > checkpoint.frameExposureCount) { + frame.exposureTrace.remove(frame.exposureTrace.size() - 1); + } + } + } + + private EvaluationResult complete( + final ProviderEvaluation details, final boolean commitExposures) { + return new EvaluationResult<>( + details, + evaluations, + commitExposures ? exposures : emptyList(), + state.evaluationTimestampMs); + } + + private String resolveCurrent(final String flagKey) { + final int depth = frames.isEmpty() ? 1 : frames.get(frames.size() - 1).depth + 1; + return resolve(flagKey, depth); + } + + private String resolve(final String flagKey, final int depth) { + if (depth > maxDependencyDepth) { + if (!memo.containsKey(flagKey) && !active.contains(flagKey)) { + throw emitFailure(flagKey, ErrorCode.GENERAL, "maximum flag dependency depth exceeded"); + } + throw new DependencyGraphException( + ErrorCode.GENERAL, "maximum flag dependency depth exceeded"); + } + if (active.contains(flagKey)) { + throw new DependencyGraphException(ErrorCode.GENERAL, "flag dependency cycle detected"); + } + + final MemoizedDependency memoized = memo.get(flagKey); + if (memoized != null) { + if (depth + memoized.descendantDepth > maxDependencyDepth) { + throw new DependencyGraphException( + ErrorCode.GENERAL, "maximum flag dependency depth exceeded"); + } + recordReachedDepth(depth + memoized.descendantDepth); + recordExposureTrace(memoized.exposureTrace); + return memoized.details.getVariant(); + } + + final Flag flag = state.config.flags == null ? null : state.config.flags.get(flagKey); + if (flag == null) { + final ProviderEvaluation missing = + error( + null, + state.config.invalidFlags != null && state.config.invalidFlags.containsKey(flagKey) + ? ErrorCode.PARSE_ERROR + : ErrorCode.FLAG_NOT_FOUND, + "prerequisite flag is missing", + state.observeFullEvaluationData); + emitEvaluation(flagKey, missing); + throw new DependencyGraphException( + missing.getErrorCode(), + missing.getErrorCode() == ErrorCode.FLAG_NOT_FOUND + ? "prerequisite flag is missing" + : "invalid prerequisite flag configuration"); + } + + active.add(flagKey); + final DependencyFrame frame = new DependencyFrame(depth); + frames.add(frame); + try { + final ProviderEvaluation details = evaluateDeclaredFlag(state, flagKey, flag); + if (details.getErrorCode() != null || Reason.ERROR.name().equals(details.getReason())) { + throw new DependencyGraphException( + details.getErrorCode() == null ? ErrorCode.GENERAL : details.getErrorCode(), + details.getErrorMessage() == null + ? "prerequisite flag evaluation failed" + : details.getErrorMessage()); + } + if (details.getVariant() == null) { + throw new DependencyGraphException( + ErrorCode.GENERAL, "prerequisite flag evaluation returned no variant"); + } + final List exposureTrace = new ArrayList<>(frame.exposureTrace); + appendUnique(exposureTrace, new DependencyExposure(flagKey, details)); + final MemoizedDependency result = + new MemoizedDependency(details, frame.maxReachedDepth - depth, exposureTrace); + memo.put(flagKey, result); + emitEvaluation(flagKey, details); + recordReachedDepthOnParent(frame.maxReachedDepth); + recordExposureTraceOnParent(exposureTrace); + return details.getVariant(); + } catch (final DependencyGraphException e) { + final ProviderEvaluation failure = + error(null, e.errorCode, e.getMessage(), state.observeFullEvaluationData); + emitEvaluation(flagKey, failure); + throw e; + } catch (final PatternSyntaxException e) { + throw emitFailure(flagKey, ErrorCode.PARSE_ERROR, e.getMessage()); + } catch (final NumberFormatException e) { + throw emitFailure(flagKey, ErrorCode.TYPE_MISMATCH, e.getMessage()); + } catch (final Exception e) { + throw emitFailure(flagKey, ErrorCode.GENERAL, e.getMessage()); + } finally { + frames.remove(frames.size() - 1); + active.remove(flagKey); + } + } + + private DependencyGraphException emitFailure( + final String flagKey, final ErrorCode errorCode, final String message) { + final DependencyGraphException failure = + new DependencyGraphException(errorCode, message == null ? errorCode.name() : message); + emitEvaluation( + flagKey, error(null, errorCode, failure.getMessage(), state.observeFullEvaluationData)); + return failure; + } + + private void emitEvaluation(final String flagKey, final ProviderEvaluation details) { + if (!evaluatedFlags.add(flagKey)) { + return; + } + evaluations.add(new DependencyEvaluation(flagKey, details)); + } + + private void recordReachedDepth(final int depth) { + if (!frames.isEmpty()) { + final DependencyFrame frame = frames.get(frames.size() - 1); + frame.maxReachedDepth = Math.max(frame.maxReachedDepth, depth); + } + } + + private void recordReachedDepthOnParent(final int depth) { + if (frames.size() > 1) { + final DependencyFrame parent = frames.get(frames.size() - 2); + parent.maxReachedDepth = Math.max(parent.maxReachedDepth, depth); + } + } + + private void recordExposureTrace(final List trace) { + for (final DependencyExposure candidate : trace) { + appendUnique(exposures, candidate); + if (!frames.isEmpty()) { + appendUnique(frames.get(frames.size() - 1).exposureTrace, candidate); + } + } + } + + private void recordExposureTraceOnParent(final List trace) { + for (final DependencyExposure candidate : trace) { + appendUnique(exposures, candidate); + if (frames.size() > 1) { + appendUnique(frames.get(frames.size() - 2).exposureTrace, candidate); + } + } + } + } + + private ProviderEvaluation evaluateDeclaredFlag( + final EvaluationState state, final String flagKey, final Flag flag) { + if (flag.variationType == null) { + throw new DependencyGraphException( + ErrorCode.PARSE_ERROR, "prerequisite flag has no variation type"); + } + switch (flag.variationType) { + case BOOLEAN: + return evaluateFlag(state, Boolean.class, flagKey, Boolean.FALSE); + case INTEGER: + return evaluateFlag(state, Integer.class, flagKey, 0); + case NUMERIC: + return evaluateFlag(state, Double.class, flagKey, 0D); + case STRING: + return evaluateFlag(state, String.class, flagKey, ""); + case JSON: + return evaluateFlag(state, Value.class, flagKey, new Value()); + default: + throw new DependencyGraphException( + ErrorCode.PARSE_ERROR, "unsupported prerequisite variation type"); + } + } + + private static void appendUnique( + final List target, final DependencyExposure candidate) { + for (final DependencyExposure existing : target) { + if (existing.flagKey.equals(candidate.flagKey)) { + return; + } + } + target.add(candidate); + } + + private static final class DependencyFrame { + private final int depth; + private int maxReachedDepth; + private final List exposureTrace = new ArrayList<>(); + + private DependencyFrame(final int depth) { + this.depth = depth; + this.maxReachedDepth = depth; + } + } + + private static final class ExposureCheckpoint { + private static final ExposureCheckpoint EMPTY = new ExposureCheckpoint(0, 0); + private final int exposureCount; + private final int frameExposureCount; + + private ExposureCheckpoint(final int exposureCount, final int frameExposureCount) { + this.exposureCount = exposureCount; + this.frameExposureCount = frameExposureCount; + } + } + + private static final class MemoizedDependency { + private final ProviderEvaluation details; + private final int descendantDepth; + private final List exposureTrace; + + private MemoizedDependency( + final ProviderEvaluation details, + final int descendantDepth, + final List exposureTrace) { + this.details = details; + this.descendantDepth = descendantDepth; + this.exposureTrace = exposureTrace; + } + } + + private static final class DependencyGraphException extends RuntimeException { + private final ErrorCode errorCode; + + private DependencyGraphException(final ErrorCode errorCode, final String message) { + super(message); + this.errorCode = errorCode; + } + } + + public static final class DependencyExposure { + public final String flagKey; + public final String variant; + public final ImmutableMetadata metadata; + + private DependencyExposure(final String flagKey, final ProviderEvaluation details) { + this.flagKey = flagKey; + this.variant = details.getVariant(); + this.metadata = details.getFlagMetadata(); + } + } + + /** Detached evaluation telemetry for one reached prerequisite. */ + public static final class DependencyEvaluation { + public final String flagKey; + public final String variant; + public final String reason; + public final ErrorCode errorCode; + public final String errorMessage; + public final ImmutableMetadata metadata; + + private DependencyEvaluation(final String flagKey, final ProviderEvaluation details) { + this.flagKey = flagKey; + this.variant = details.getVariant(); + this.reason = details.getReason(); + this.errorCode = details.getErrorCode(); + this.errorMessage = details.getErrorMessage(); + this.metadata = details.getFlagMetadata(); + } + } + + /** Root evaluation plus the telemetry candidates accumulated while evaluating its graph. */ + public static final class EvaluationResult { + public final ProviderEvaluation details; + public final List dependencyEvaluations; + public final List dependencyExposures; + public final long evaluationTimestampMs; + + private EvaluationResult( + final ProviderEvaluation details, + final List dependencyEvaluations, + final List dependencyExposures, + final long evaluationTimestampMs) { + this.details = details; + this.dependencyEvaluations = + dependencyEvaluations.isEmpty() + ? emptyList() + : unmodifiableList(new ArrayList<>(dependencyEvaluations)); + this.dependencyExposures = + dependencyExposures.isEmpty() + ? emptyList() + : unmodifiableList(new ArrayList<>(dependencyExposures)); + this.evaluationTimestampMs = evaluationTimestampMs; + } + } + /** Tells whether an exposure was already recorded, so it is not sent again. */ @FunctionalInterface public interface ExposureLookup { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java index 484d126cecf..855fb1f31ca 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java @@ -5,6 +5,7 @@ public final class ConditionConfiguration { public final ConditionOperator operator; public final String attribute; + public final String flagEvaluationKey; public final Object value; // The validated, parsed SemVer condition value. Set during configuration preprocessing @@ -16,8 +17,17 @@ public final class ConditionConfiguration { public ConditionConfiguration( final ConditionOperator operator, final String attribute, final Object value) { + this(operator, attribute, null, value); + } + + public ConditionConfiguration( + final ConditionOperator operator, + final String attribute, + final String flagEvaluationKey, + final Object value) { this.operator = operator; this.attribute = attribute; + this.flagEvaluationKey = flagEvaluationKey; this.value = value; } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/EvaluatorParams.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/EvaluatorParams.java new file mode 100644 index 00000000000..aef9e2b0440 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/EvaluatorParams.java @@ -0,0 +1,16 @@ +package com.datadog.openfeature.internal.ufc; + +/** Optional evaluator controls carried by UFC. */ +public final class EvaluatorParams { + public final Object maxDependencyDepth; + public final boolean maxDependencyDepthPresent; + + public EvaluatorParams(final Object maxDependencyDepth) { + this(maxDependencyDepth, maxDependencyDepth != null); + } + + public EvaluatorParams(final Object maxDependencyDepth, final boolean maxDependencyDepthPresent) { + this.maxDependencyDepth = maxDependencyDepth; + this.maxDependencyDepthPresent = maxDependencyDepthPresent; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java index 96aa6288154..2e645df3a82 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java @@ -12,6 +12,7 @@ public final class ServerConfiguration { public final Environment environment; public final Map flags; + public final EvaluatorParams evaluatorParams; // Flags that could not be parsed or validated. The key is the flag key; the value is the error // type (e.g. "invalid_semver_comparand"). Set during configuration preprocessing, not from JSON. @@ -23,10 +24,21 @@ public ServerConfiguration( final Boolean observeFullEvaluationData, final Environment environment, final Map flags) { + this(createdAt, format, observeFullEvaluationData, environment, flags, null); + } + + public ServerConfiguration( + final String createdAt, + final String format, + final Boolean observeFullEvaluationData, + final Environment environment, + final Map flags, + final EvaluatorParams evaluatorParams) { this.createdAt = createdAt; this.format = format; this.observeFullEvaluationData = observeFullEvaluationData; this.environment = environment; this.flags = flags; + this.evaluatorParams = evaluatorParams; } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index d6fb0e435f4..fde311825b2 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -149,7 +149,8 @@ private static ServerConfiguration toServerConfiguration(@Nullable final Object asString(json.get("format"), "format"), lenientBoolean(json.get("observeFullEvaluationData")), toEnvironment(json.get("environment")), - toFlags(json.get("flags"), invalidFlags)); + toFlags(json.get("flags"), invalidFlags), + toEvaluatorParams(json.get("evaluatorParams"))); if (!invalidFlags.isEmpty()) { configuration.invalidFlags = invalidFlags; } @@ -159,6 +160,16 @@ private static ServerConfiguration toServerConfiguration(@Nullable final Object } } + @Nullable + private static EvaluatorParams toEvaluatorParams(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "evaluatorParams"); + return new EvaluatorParams( + json.get("maxDependencyDepth"), json.containsKey("maxDependencyDepth")); + } + @Nullable private static Environment toEnvironment(@Nullable final Object value) { if (value == null) { @@ -264,10 +275,36 @@ private static ConditionConfiguration toCondition(@Nullable final Object value) return null; } final Map json = asObject(value, "condition"); - return new ConditionConfiguration( - asEnum(json.get("operator"), ConditionOperator.class, "operator"), - asString(json.get("attribute"), "attribute"), - json.get("value")); + final boolean hasAttribute = json.containsKey("attribute"); + final boolean hasFlagEvaluation = json.containsKey("flagEvaluation"); + if (hasAttribute == hasFlagEvaluation) { + throw new IllegalArgumentException( + "condition must contain exactly one of attribute or flagEvaluation"); + } + final String attribute = hasAttribute ? asString(json.get("attribute"), "attribute") : null; + final String flagEvaluationKey = + hasFlagEvaluation ? toFlagEvaluationKey(json.get("flagEvaluation")) : null; + final ConditionOperator operator = + asEnum(json.get("operator"), ConditionOperator.class, "operator"); + if (flagEvaluationKey != null + && operator != ConditionOperator.ONE_OF + && operator != ConditionOperator.NOT_ONE_OF) { + throw new IllegalArgumentException( + "flagEvaluation conditions support only ONE_OF and NOT_ONE_OF"); + } + return new ConditionConfiguration(operator, attribute, flagEvaluationKey, json.get("value")); + } + + private static String toFlagEvaluationKey(@Nullable final Object value) { + final Map json = asObject(value, "flagEvaluation"); + if (json.size() != 1 || !json.containsKey("key")) { + throw new IllegalArgumentException("flagEvaluation must contain only key"); + } + final String key = asString(json.get("key"), "flagEvaluation.key"); + if (key.isEmpty()) { + throw new IllegalArgumentException("flagEvaluation.key must not be empty"); + } + return key; } @Nullable @@ -579,6 +616,17 @@ private static void validateConditionOperands(final String flagKey, final Alloca throw new IllegalArgumentException( "flag \"" + flagKey + "\" has a non-list condition"); } + // A flag evaluation compares the dependency's variant key, which is always a String. + // Attribute membership deliberately retains the evaluator's boolean/numeric equality + // semantics. + if (condition.flagEvaluationKey != null) { + for (final Object member : (List) condition.value) { + if (!(member instanceof String)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-string membership operand"); + } + } + } break; case IS_NULL: if (!(condition.value instanceof Boolean)) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java index ee9109c5a80..3ea5ae87bd4 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -47,6 +47,7 @@ import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.multiprovider.MultiProvider; import io.opentelemetry.api.GlobalOpenTelemetry; @@ -114,6 +115,8 @@ public void runtimeDefault(final String flagKey, final Object defaultValue) { private FeatureFlagsRuntime runtime; private CapturingEvaluator evaluator; private Client client; + private boolean failDependencyPublisher; + private boolean mutateDependencyRecord; @BeforeEach void setUp() throws Exception { @@ -509,6 +512,167 @@ public void after( assertEquals(2, this.customerEvaluations.size()); } + @Test + void dependencyTelemetryHonorsEnabledProviderOptOut() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(true), + dependentConfiguration()); + + final FlagEvaluationDetails optedOut = + this.client.getIntegerDetails("root", 0, context("subject-1", new Value(true))); + + assertEquals(1, optedOut.getValue()); + assertTelemetryCounts(0); + assertEquals(2, this.exposures.size(), "dependency and root exposures remain independent"); + } + + @Test + void dependencyTelemetryIncludesMetricsWhenProviderOptOutIsDisabled() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + dependentConfiguration()); + + final FlagEvaluationDetails included = + this.client.getIntegerDetails("root", 0, context("subject-2", new Value(true))); + + assertEquals(1, included.getValue()); + assertEquals(2, this.events.size(), "dependency and root evaluation events"); + assertEquals("dependency", this.events.get(0).flagKey, "dependency publishes before root"); + assertEquals("root", this.events.get(1).flagKey); + assertEquals(2, this.metrics.get(), "dependency and root OTel metrics"); + assertEquals(1, this.spans.size(), "only the ordinary root span hook runs"); + assertEquals(2, this.exposures.size()); + } + + @TableTest({ + "initial | after", + "false | true ", + "true | false" + }) + void dependencyAndRootTelemetryShareAfterHookOptOutBoundary( + final boolean initial, final boolean after) throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(true), + dependentConfiguration()); + final MutableContext context = new MutableContext("subject").add(PARAMETER, initial); + this.api.addHooks( + new Hook() { + @Override + public void after( + final HookContext hookContext, + final FlagEvaluationDetails details, + final Map hints) { + context.add(PARAMETER, after); + } + }); + + final FlagEvaluationDetails details = + this.client.getIntegerDetails("root", 0, context); + + assertNull(details.getErrorCode()); + assertEquals(1, details.getValue()); + assertTelemetryCounts(0); + assertEquals(2, this.exposures.size(), "evaluation opt-out does not suppress exposures"); + } + + @Test + void directProviderCallEmitsNeitherRootNorDependencyTelemetry() throws Exception { + this.evaluator = new CapturingEvaluator(dependentConfiguration()); + final Provider provider = + provider( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), this.evaluator); + final MutableContext context = new MutableContext("subject"); + provider.initialize(context); + + final ProviderEvaluation details = provider.getIntegerEvaluation("root", 0, context); + + assertNull(details.getErrorCode()); + assertEquals(1, details.getValue()); + assertTelemetryCounts(0); + assertTrue(this.exposures.isEmpty()); + assertTrue(this.customerEvaluations.isEmpty()); + provider.shutdown(); + } + + @Test + void failingAfterHookDiscardsBufferedDependencyExposures() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + dependentConfiguration()); + this.api.addHooks( + new Hook() { + @Override + public void after( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + throw new IllegalStateException("application hook failed"); + } + }); + + final FlagEvaluationDetails details = + this.client.getIntegerDetails("root", 0, context("subject", new Value(false))); + + assertEquals(ErrorCode.GENERAL, details.getErrorCode()); + assertEquals(0, details.getValue()); + assertEquals(0, this.exposures.size()); + } + + @Test + void failingDependencyPublisherDoesNotChangeTheRootOrExposures() throws Exception { + this.failDependencyPublisher = true; + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + dependentConfiguration()); + + final FlagEvaluationDetails details = + this.client.getIntegerDetails("root", 0, context("subject", new Value(false))); + + assertEquals(1, details.getValue()); + assertEquals(1, this.events.size(), "only root telemetry survives publisher failure"); + assertEquals(1, this.metrics.get(), "only root metrics survive publisher failure"); + assertEquals(2, this.exposures.size(), "publisher failure does not affect exposures"); + } + + @Test + void dependencyRecordCannotBeMutatedByItsPublisher() throws Exception { + this.mutateDependencyRecord = true; + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + dependentConfiguration()); + + final FlagEvaluationDetails details = + this.client.getIntegerDetails("root", 0, context("subject", new Value(false))); + + assertEquals(1, details.getValue()); + assertEquals(1, this.events.size(), "immutable record rejects publisher mutation"); + assertEquals(2, this.exposures.size(), "mutation attempt does not affect exposures"); + } + + @Test + void parallelDependentRootsKeepIndependentReturnedBatches() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + dependentConfiguration()); + final ExecutorService executor = Executors.newFixedThreadPool(2); + try { + final Future> first = + executor.submit( + () -> this.client.getIntegerDetails("root", 0, context("first", new Value(false)))); + final Future> second = + executor.submit( + () -> this.client.getIntegerDetails("root", 0, context("second", new Value(false)))); + + assertEquals(1, first.get(5, SECONDS).getValue()); + assertEquals(1, second.get(5, SECONDS).getValue()); + } finally { + executor.shutdownNow(); + } + assertEquals(4, this.events.size(), "dependency and root for each evaluation"); + assertEquals(4, this.metrics.get()); + assertEquals(4, this.exposures.size()); + } + @Test void concurrentSubjectsCannotChangeEachOthersDecision() throws Exception { start(); @@ -698,7 +862,12 @@ private void start() throws Exception { } private void start(final Provider.Options options) throws Exception { - this.evaluator = new CapturingEvaluator(defaultConfiguration()); + start(options, defaultConfiguration()); + } + + private void start(final Provider.Options options, final ServerConfiguration configuration) + throws Exception { + this.evaluator = new CapturingEvaluator(configuration); this.api.setProviderAndWait(provider(options, this.evaluator)); this.client = this.api.getClient(); } @@ -745,6 +914,48 @@ private static ServerConfiguration defaultConfiguration() { return new ServerConfiguration("", "", false, null, singletonMap("flag", flag)); } + private static ServerConfiguration dependentConfiguration() { + final Allocation dependencyAllocation = + new Allocation( + "dependency-allocation", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 7)), + true); + final Flag dependency = + new Flag( + "dependency", + true, + ValueType.INTEGER, + singletonMap("on", new Variant("on", 1)), + singletonList(dependencyAllocation)); + final Rule rule = + new Rule( + singletonList( + new ConditionConfiguration( + ConditionOperator.ONE_OF, null, "dependency", singletonList("on")))); + final Allocation rootAllocation = + new Allocation( + "root-allocation", + singletonList(rule), + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 7)), + true); + final Flag root = + new Flag( + "root", + true, + ValueType.INTEGER, + singletonMap("on", new Variant("on", 1)), + singletonList(rootAllocation)); + final Map flags = new HashMap<>(); + flags.put("dependency", dependency); + flags.put("root", root); + return new ServerConfiguration("", "", false, null, flags); + } + private static ServerConfiguration targetingConfiguration( final boolean observeFullEvaluationData) { final Rule rule = @@ -841,6 +1052,29 @@ boolean isExposureDeliveryEnabled() { void recordExposure(final ExposureEvent event) { EvaluationTelemetryOptOutTest.this.exposures.add(event); } + + @Override + void publishDependencyEvaluations( + final DependencyEvaluationTrace trace, + final EvaluationContext context, + final com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter writer) { + if (EvaluationTelemetryOptOutTest.this.failDependencyPublisher) { + throw new IllegalStateException("publisher failed"); + } + if (EvaluationTelemetryOptOutTest.this.mutateDependencyRecord) { + trace.evaluations.get(0).metadata.asUnmodifiableMap().clear(); + } + super.publishDependencyEvaluations(trace, context, writer); + } + + @Override + void recordDependencyMetrics( + final DependencyEvaluationTrace trace, final FlagEvalMetrics dependencyMetrics) { + if (EvaluationTelemetryOptOutTest.this.failDependencyPublisher) { + throw new IllegalStateException("publisher failed"); + } + super.recordDependencyMetrics(trace, dependencyMetrics); + } } /** A flag evaluation pipeline capturing enqueued events, never started. */ diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java index e705bb0ffef..2f381b5885b 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java @@ -4,6 +4,7 @@ import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_CACHE_HIT; import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; +import static com.datadog.openfeature.internal.ufc.ConditionOperator.ONE_OF; import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; @@ -16,15 +17,19 @@ import com.datadog.openfeature.Provider.Options; import com.datadog.openfeature.internal.bridge.v1.AgentBridge; import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.evaluation.FlagEvaluator.DependencyExposure; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ConditionConfiguration; import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.Rule; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.Split; import com.datadog.openfeature.internal.ufc.ValueType; import com.datadog.openfeature.internal.ufc.Variant; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; import dev.openfeature.sdk.Hook; @@ -32,6 +37,7 @@ import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.multiprovider.MultiProvider; import java.util.HashMap; import java.util.List; @@ -145,6 +151,184 @@ void sendsNothingForAnAllocationThatDoesNotLogExposures() { assertNull(cacheHit(details)); } + @Test + void publishesEligibleDependencyExposuresThroughTheRootHookLifecycle() throws Exception { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(dependentConfiguration(true), true); + OpenFeatureAPI.getInstance() + .setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), dependent)); + + OpenFeatureAPI.getInstance() + .getClient() + .getIntegerDetails("root", 0, new MutableContext("user-1")); + + assertEquals(1, dependent.sentToDatadog.size()); + assertEquals("dependency", dependent.sentToDatadog.get(0).flag.key); + } + + @Test + void doesNotStageIneligibleDependencyExposures() throws Exception { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(dependentConfiguration(false), true); + OpenFeatureAPI.getInstance() + .setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), dependent)); + + OpenFeatureAPI.getInstance() + .getClient() + .getIntegerDetails("root", 0, new MutableContext("user-1")); + + assertTrue(dependent.sentToDatadog.isEmpty()); + } + + @Test + void directProviderEvaluationDoesNotRetainOrExposeDependencyState() throws Exception { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(dependentConfiguration(true), true); + final Provider provider = new Provider(new Options().initTimeout(10, SECONDS), dependent); + final MutableContext context = new MutableContext("user-1"); + provider.initialize(context); + + final ProviderEvaluation evaluation = + provider.getIntegerEvaluation("root", 0, context); + + assertTrue( + evaluation.getFlagMetadata() == null + || !evaluation + .getFlagMetadata() + .asUnmodifiableMap() + .containsKey("__dd_dependency_exposures_token")); + new ExposureLoggingHook(() -> dependent) + .finallyAfter( + HookContext.builder() + .flagKey("root") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(context) + .build(), + FlagEvaluationDetails.from(evaluation, "root"), + emptyMap()); + assertTrue(dependent.sentToDatadog.isEmpty()); + + provider.shutdown(); + } + + @Test + void reentrantDirectEvaluationCannotReplaceTheOuterRootsDependencyExposures() { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(reentrantDependentConfiguration(), true); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> dependent); + final MutableContext outerContext = new MutableContext("subject-a"); + final MutableContext reentrantContext = new MutableContext("subject-b"); + final HookContext outerHookContext = + HookContext.builder() + .flagKey("root-a") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(outerContext) + .build(); + + hook.before(outerHookContext, emptyMap()); + final ProviderEvaluation outerEvaluation = + dependent.evaluate(Integer.class, "root-a", 0, outerContext); + dependent.evaluate(Integer.class, "root-b", 0, reentrantContext); + hook.finallyAfter( + outerHookContext, FlagEvaluationDetails.from(outerEvaluation, "root-a"), emptyMap()); + + assertEquals(1, dependent.sentToDatadog.size()); + assertEquals("dependency-a", dependent.sentToDatadog.get(0).flag.key); + assertEquals("subject-a", dependent.sentToDatadog.get(0).subject.id); + } + + @Test + void unmatchedNestedFinallyCannotPopTheOuterDependencyExposureScope() { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(reentrantDependentConfiguration(), true); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> dependent); + final MutableContext outerContext = new MutableContext("subject-a"); + final HookContext outerHookContext = + HookContext.builder() + .flagKey("root-a") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(outerContext) + .build(); + final HookContext innerHookContext = + HookContext.builder() + .flagKey("root-b") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(new MutableContext("subject-b")) + .build(); + + hook.before(outerHookContext, emptyMap()); + final ProviderEvaluation outerEvaluation = + dependent.evaluate(Integer.class, "root-a", 0, outerContext); + + // OpenFeature invokes finally hooks for the inner evaluation even when an earlier before hook + // throws before this hook's before method is reached. + hook.finallyAfter( + innerHookContext, + FlagEvaluationDetails.builder() + .flagKey("root-b") + .value(0) + .errorCode(ErrorCode.GENERAL) + .build(), + emptyMap()); + hook.finallyAfter( + outerHookContext, FlagEvaluationDetails.from(outerEvaluation, "root-a"), emptyMap()); + + assertEquals(1, dependent.sentToDatadog.size()); + assertEquals("dependency-a", dependent.sentToDatadog.get(0).flag.key); + assertEquals("subject-a", dependent.sentToDatadog.get(0).subject.id); + assertTrue(dependent.endDependencyExposureScope().isEmpty()); + + // A subsequent lifecycle proves the hook's own ThreadLocal stack was removed as well. + hook.before(innerHookContext, emptyMap()); + final ProviderEvaluation innerEvaluation = + dependent.evaluate(Integer.class, "root-b", 0, innerHookContext.getCtx()); + hook.finallyAfter( + innerHookContext, FlagEvaluationDetails.from(innerEvaluation, "root-b"), emptyMap()); + assertEquals(2, dependent.sentToDatadog.size()); + assertEquals("dependency-b", dependent.sentToDatadog.get(1).flag.key); + } + + @Test + void emptyMatchingEvaluationSealsTheOuterDependencyExposureScope() { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(dependentConfiguration(false), true); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> dependent); + final MutableContext context = new MutableContext("subject-a"); + final HookContext hookContext = + HookContext.builder() + .flagKey("root") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(context) + .build(); + + hook.before(hookContext, emptyMap()); + final ProviderEvaluation outerEvaluation = + dependent.evaluate(Integer.class, "root", 0, context); + dependent.accept(dependentConfiguration(true)); + dependent.evaluate(Integer.class, "root", 0, context); + hook.finallyAfter(hookContext, FlagEvaluationDetails.from(outerEvaluation, "root"), emptyMap()); + + assertTrue(dependent.sentToDatadog.isEmpty()); + } + + @Test + void shutdownDiscardsTheCurrentDependencyExposureScope() { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(dependentConfiguration(true), true); + final MutableContext context = new MutableContext("user-1"); + dependent.beginDependencyExposureScope("root", context); + dependent.evaluate(Integer.class, "root", 0, context); + + dependent.shutdown(); + + assertTrue(dependent.endDependencyExposureScope().isEmpty()); + } + @Test void recordsButDoesNotSendWhenDatadogLoggingIsOff() { final ExposureTestEvaluator notSending = @@ -254,6 +438,29 @@ void recordExposure(final ExposureEvent event) { context, details("off", exposureMetadata(true, false, "a")), emptyMap())); } + @Test + void dependencyExposureFailureDoesNotSuppressTheRootExposure() { + final ExposureTestEvaluator failingDependencies = + new ExposureTestEvaluator(configuration("on", Boolean.TRUE), true) { + @Override + void recordDependencyExposures( + final List exposures, final EvaluationContext context) { + throw new IllegalStateException("dependency queue failure"); + } + }; + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> failingDependencies); + final HookContext context = hookContext(); + + hook.before(context, emptyMap()); + assertDoesNotThrow( + () -> + hook.finallyAfter( + context, details("on", exposureMetadata(true, false, "a")), emptyMap())); + + assertEquals(1, failingDependencies.sentToDatadog.size()); + assertEquals("flag", failingDependencies.sentToDatadog.get(0).flag.key); + } + private static Object cacheHit(final FlagEvaluationDetails details) { return details.getFlagMetadata().asUnmodifiableMap().get(EXPOSURE_CACHE_HIT); } @@ -305,4 +512,60 @@ private static ServerConfiguration configuration(final String variantKey, final "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); return new ServerConfiguration("", "", false, null, flags); } + + private static ServerConfiguration dependentConfiguration(final boolean dependencyDoLog) { + final Map flags = new HashMap<>(); + addDependentFlagPair(flags, "root", "dependency", dependencyDoLog); + return new ServerConfiguration("", "", false, null, flags); + } + + private static ServerConfiguration reentrantDependentConfiguration() { + final Map flags = new HashMap<>(); + addDependentFlagPair(flags, "root-a", "dependency-a", true); + addDependentFlagPair(flags, "root-b", "dependency-b", true); + return new ServerConfiguration("", "", false, null, flags); + } + + private static void addDependentFlagPair( + final Map flags, + final String rootKey, + final String dependencyKey, + final boolean dependencyDoLog) { + final Map dependencyVariations = new HashMap<>(); + dependencyVariations.put("on", new Variant("on", "on-value")); + final Allocation dependencyAllocation = + new Allocation( + dependencyKey + "-allocation", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 8)), + dependencyDoLog); + final Flag dependency = + new Flag( + dependencyKey, + true, + ValueType.STRING, + dependencyVariations, + singletonList(dependencyAllocation)); + + final Map rootVariations = new HashMap<>(); + rootVariations.put("selected", new Variant("selected", 1)); + final Rule dependencyRule = + new Rule( + singletonList( + new ConditionConfiguration(ONE_OF, null, dependencyKey, singletonList("on")))); + final Allocation rootAllocation = + new Allocation( + rootKey + "-allocation", + singletonList(dependencyRule), + null, + null, + singletonList(new Split(emptyList(), "selected", emptyMap(), 9)), + false); + flags.put(dependencyKey, dependency); + flags.put( + rootKey, + new Flag(rootKey, true, ValueType.INTEGER, rootVariations, singletonList(rootAllocation))); + } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java index 9dc8168dd5c..f241fbc03ff 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java @@ -162,6 +162,9 @@ void sendsApiKeyAndMetadataHeaders() throws Exception { assertEquals("GET", request.method); assertEquals("secret", request.header("DD-API-KEY")); assertEquals("gzip", request.header("Accept-Encoding")); + assertEquals( + CdnConfigurationSource.DEPENDENT_FLAG_CAPABILITY, + request.header(CdnConfigurationSource.FEATURE_FLAGS_CAPABILITIES_HEADER)); assertEquals("java", request.header("Datadog-Meta-Lang")); assertNull(request.header("If-None-Match")); } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorTest.java new file mode 100644 index 00000000000..64dc44ea0cb --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorTest.java @@ -0,0 +1,469 @@ +package com.datadog.openfeature.internal.evaluation; + +import static com.datadog.openfeature.internal.ufc.ConditionOperator.NOT_ONE_OF; +import static com.datadog.openfeature.internal.ufc.ConditionOperator.ONE_OF; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.ExperimentationOptOutMode; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ConditionConfiguration; +import com.datadog.openfeature.internal.ufc.EvaluatorParams; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.Rule; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DependentFlagEvaluatorTest { + + @Test + void appliesFutureExperimentOptOutToDependenciesUsingTheRootCutoff() { + final FlagEvaluator evaluator = evaluator(ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY); + final Map dependencyVariations = new HashMap<>(); + dependencyVariations.put("future", new Variant("future", "future-value")); + dependencyVariations.put("on", new Variant("on", "on-value")); + final Allocation future = + new Allocation( + "future", + null, + null, + null, + singletonList(new Split(emptyList(), "future", emptyMap(), 1)), + true, + Instant.parse("2026-10-20T00:00:00Z")); + final Allocation retained = + new Allocation( + "retained", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 1)), + true, + Instant.parse("2026-10-18T00:00:00Z")); + final Map flags = new HashMap<>(); + flags.put( + "dependency", + new Flag( + "dependency", + true, + ValueType.STRING, + dependencyVariations, + Arrays.asList(future, retained))); + flags.put("root", stringFlag("root", "on", dependencyRule("dependency", "on"), false)); + final MutableContext context = new MutableContext("u"); + context.add(ExperimentationOptOut.ATTRIBUTE, "2026-10-19T12:00:00Z"); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace(config(flags, null), String.class, "root", "default", context); + + assertEquals("on-value", evaluation.details.getValue()); + assertEquals(singletonList("dependency:on"), evaluations(evaluation)); + assertEquals(singletonList("dependency"), exposures(evaluation)); + } + + @Test + void propagatesInvalidDependencyActivationUnderFutureExperimentOptOut() { + final FlagEvaluator evaluator = evaluator(ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY); + final Map dependencyVariations = new HashMap<>(); + dependencyVariations.put("on", new Variant("on", "on-value")); + final Allocation invalid = + new Allocation( + "invalid", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 1)), + true, + null, + true); + final Map flags = new HashMap<>(); + flags.put( + "dependency", + new Flag( + "dependency", true, ValueType.STRING, dependencyVariations, singletonList(invalid))); + flags.put("root", stringFlag("root", "on", dependencyRule("dependency", "on"), false)); + final MutableContext context = new MutableContext("u"); + context.add(ExperimentationOptOut.ATTRIBUTE, "2026-10-19T12:00:00Z"); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace(config(flags, null), String.class, "root", "default", context); + + assertEquals(ErrorCode.PARSE_ERROR, evaluation.details.getErrorCode()); + assertEquals(singletonList("dependency:PARSE_ERROR"), evaluations(evaluation)); + assertTrue(evaluation.dependencyExposures.isEmpty()); + } + + @Test + void memoizesDependenciesAndReplaysTheirExposureAfterRuleRollback() { + final FlagEvaluator evaluator = evaluator(); + final Map flags = new HashMap<>(); + flags.put("dependency", stringFlag("dependency", "on", null, true)); + flags.put( + "root", + integerFlag( + "root", + Arrays.asList( + allocation("rejected", dependencyRule("dependency", "off"), "selected", false), + allocation("accepted", dependencyRule("dependency", "on"), "selected", false)))); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace( + config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + final ProviderEvaluation result = evaluation.details; + + assertEquals(1, result.getValue()); + assertEquals(singletonList("dependency:on"), evaluations(evaluation)); + assertEquals(singletonList("dependency"), exposures(evaluation)); + } + + @Test + void rollsBackDependencyExposuresWhenTheRootEvaluationFails() { + final FlagEvaluator evaluator = evaluator(); + final Map flags = new HashMap<>(); + flags.put("dependency", stringFlag("dependency", "on", null, true)); + flags.put( + "root", + integerFlag( + "root", + singletonList( + allocation("broken", dependencyRule("dependency", "on"), "missing", false)))); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace( + config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + final ProviderEvaluation result = evaluation.details; + + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals(singletonList("dependency:on"), evaluations(evaluation)); + assertTrue(evaluation.dependencyExposures.isEmpty()); + } + + @Test + void propagatesCyclesAndEmitsAnErrorForEveryReachedDependency() { + final FlagEvaluator evaluator = evaluator(); + final Map flags = new HashMap<>(); + flags.put("a", stringFlag("a", "on", dependencyRule("b", "on"), false)); + flags.put("b", stringFlag("b", "on", dependencyRule("a", "on"), false)); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace( + config(flags, null), String.class, "a", "default", new MutableContext("u")); + final ProviderEvaluation result = evaluation.details; + + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals(singletonList("b:GENERAL"), evaluations(evaluation)); + } + + @Test + void enforcesTheConfiguredMaximumDependencyDepth() { + final FlagEvaluator evaluator = evaluator(); + final Map flags = new HashMap<>(); + flags.put("root", stringFlag("root", "on", dependencyRule("a", "on"), false)); + flags.put("a", stringFlag("a", "on", dependencyRule("b", "on"), false)); + flags.put("b", stringFlag("b", "on", null, false)); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace( + config(flags, new EvaluatorParams(1D)), + String.class, + "root", + "default", + new MutableContext("u")); + final ProviderEvaluation result = evaluation.details; + + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals(Arrays.asList("b:GENERAL", "a:GENERAL"), evaluations(evaluation)); + } + + @Test + void memoizedSubtreeDepthFailureDoesNotReemitMemoizedNodeInEitherOrder() { + for (final List rootDependencies : + Arrays.asList(Arrays.asList("a", "b"), Arrays.asList("b", "a"))) { + final FlagEvaluator evaluator = evaluator(); + final Map flags = new HashMap<>(); + flags.put("leaf", stringFlag("leaf", "on", null, true)); + flags.put("a", stringFlag("a", "on", dependencyRule("leaf", "on"), true)); + flags.put("b", stringFlag("b", "on", dependencyRule("a", "on"), true)); + final Rule rootRule = + new Rule( + Arrays.asList( + new ConditionConfiguration( + ONE_OF, null, rootDependencies.get(0), singletonList("on")), + new ConditionConfiguration( + ONE_OF, null, rootDependencies.get(1), singletonList("on")))); + flags.put("root", stringFlag("root", "on", rootRule, true)); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace( + config(flags, new EvaluatorParams(2D)), + String.class, + "root", + "default", + new MutableContext("u")); + final ProviderEvaluation result = evaluation.details; + + assertEquals(ErrorCode.GENERAL, result.getErrorCode(), rootDependencies.toString()); + final List evaluationKeys = new ArrayList<>(); + for (final String record : evaluations(evaluation)) { + evaluationKeys.add(record.substring(0, record.indexOf(':'))); + } + assertEquals(Arrays.asList("leaf", "a", "b"), evaluationKeys); + assertTrue(evaluation.dependencyExposures.isEmpty()); + } + } + + @Test + void validatesInvalidMaximumDependencyDepthOnlyWhenADependencyIsReached() { + final FlagEvaluator evaluator = evaluator(); + final Map flags = new HashMap<>(); + flags.put("ordinary", stringFlag("ordinary", "on", null, false)); + flags.put("dependency", stringFlag("dependency", "on", null, false)); + flags.put( + "dependent", stringFlag("dependent", "on", dependencyRule("dependency", "on"), false)); + final ServerConfiguration config = config(flags, new EvaluatorParams(1.5D)); + + final ProviderEvaluation ordinary = + evaluator.evaluate(config, String.class, "ordinary", "default", new MutableContext("u")); + final ProviderEvaluation dependent = + evaluator.evaluate(config, String.class, "dependent", "default", new MutableContext("u")); + + assertEquals("on-value", ordinary.getValue()); + assertEquals(ErrorCode.PARSE_ERROR, dependent.getErrorCode()); + assertNotNull(dependent.getFlagMetadata()); + } + + @Test + void rejectsExplicitNullMaximumDependencyDepthButDefaultsAnEmptyObject() { + final Map flags = new HashMap<>(); + flags.put("ordinary", stringFlag("ordinary", "on", null, false)); + flags.put("dependency", stringFlag("dependency", "on", null, false)); + flags.put( + "dependent", stringFlag("dependent", "on", dependencyRule("dependency", "on"), false)); + final FlagEvaluator evaluator = evaluator(); + + assertEquals( + "on-value", + evaluator + .evaluate( + config(flags, new EvaluatorParams(null, true)), + String.class, + "ordinary", + "default", + new MutableContext("u")) + .getValue()); + assertEquals( + ErrorCode.PARSE_ERROR, + evaluator + .evaluate( + config(flags, new EvaluatorParams(null, true)), + String.class, + "dependent", + "default", + new MutableContext("u")) + .getErrorCode()); + assertEquals( + "on-value", + evaluator + .evaluate( + config(flags, new EvaluatorParams(null, false)), + String.class, + "dependent", + "default", + new MutableContext("u")) + .getValue()); + } + + @Test + void rejectsNonStringDependencyMembershipOperandsWithParseError() throws Exception { + final ServerConfiguration config = + UniversalFlagConfigParser.parse( + ("{\"flags\":{\"root\":{\"key\":\"root\",\"enabled\":true," + + "\"variationType\":\"STRING\",\"variations\":{\"on\":{\"key\":\"on\"," + + "\"value\":\"on-value\"}},\"allocations\":[{\"key\":\"allocation\"," + + "\"rules\":[{\"conditions\":[{\"operator\":\"ONE_OF\"," + + "\"flagEvaluation\":{\"key\":\"dependency\"},\"value\":[\"on\",1]}]}]," + + "\"splits\":[{\"variationKey\":\"on\",\"shards\":[]}]}]}}}") + .getBytes(UTF_8)); + + final ProviderEvaluation result = + evaluator().evaluate(config, String.class, "root", "default", new MutableContext("u")); + + assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode()); + } + + @Test + void supportsNotOneOfAndShortCircuitsBeforeAFatalDependency() { + final FlagEvaluator evaluator = evaluator(); + final Map flags = new HashMap<>(); + flags.put("dependency", stringFlag("dependency", "on", null, false)); + final Rule rejectedBeforeMissing = + new Rule( + Arrays.asList( + new ConditionConfiguration(ONE_OF, "gate", null, singletonList("open")), + new ConditionConfiguration(ONE_OF, null, "missing", singletonList("on")))); + flags.put( + "root", + integerFlag( + "root", + Arrays.asList( + allocation("short-circuit", rejectedBeforeMissing, "selected", false), + allocation( + "not-one-of", + new Rule( + singletonList( + new ConditionConfiguration( + NOT_ONE_OF, null, "dependency", singletonList("off")))), + "selected", + false)))); + final MutableContext context = new MutableContext("u"); + context.add("gate", "closed"); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace(config(flags, null), Integer.class, "root", 0, context); + final ProviderEvaluation result = evaluation.details; + + assertEquals(1, result.getValue()); + assertEquals(singletonList("dependency:on"), evaluations(evaluation)); + } + + @Test + void emitsMissingAndInvalidPrerequisiteErrors() { + final Map missingFlags = new HashMap<>(); + missingFlags.put("root", stringFlag("root", "on", dependencyRule("missing", "on"), false)); + final FlagEvaluator.EvaluationResult missingEvaluation = + evaluator() + .evaluateWithTrace( + config(missingFlags, null), + String.class, + "root", + "default", + new MutableContext("u")); + assertEquals(ErrorCode.FLAG_NOT_FOUND, missingEvaluation.details.getErrorCode()); + assertEquals(singletonList("missing:FLAG_NOT_FOUND"), evaluations(missingEvaluation)); + + final ServerConfiguration invalidConfig = config(missingFlags, null); + invalidConfig.invalidFlags = singletonMap("missing", "invalid_flag"); + final FlagEvaluator.EvaluationResult invalidEvaluation = + evaluator() + .evaluateWithTrace( + invalidConfig, String.class, "root", "default", new MutableContext("u")); + assertEquals(ErrorCode.PARSE_ERROR, invalidEvaluation.details.getErrorCode()); + assertEquals(singletonList("missing:PARSE_ERROR"), evaluations(invalidEvaluation)); + } + + @Test + void rollsBackATransitiveTraceWhenTheRootAllocationHasNoAssignment() { + final FlagEvaluator evaluator = evaluator(); + final Map flags = new HashMap<>(); + flags.put("leaf", stringFlag("leaf", "on", null, true)); + flags.put("parent", stringFlag("parent", "on", dependencyRule("leaf", "on"), true)); + final Allocation noAssignment = + new Allocation( + "no-assignment", + singletonList(dependencyRule("parent", "on")), + null, + null, + emptyList(), + false); + flags.put( + "root", + integerFlag( + "root", Arrays.asList(noAssignment, allocation("fallback", null, "selected", false)))); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithTrace( + config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + final ProviderEvaluation result = evaluation.details; + + assertEquals(1, result.getValue()); + assertEquals(Arrays.asList("leaf:on", "parent:on"), evaluations(evaluation)); + assertTrue(evaluation.dependencyExposures.isEmpty()); + } + + private static FlagEvaluator evaluator() { + return evaluator(ExperimentationOptOutMode.DISABLED); + } + + private static FlagEvaluator evaluator(final ExperimentationOptOutMode mode) { + return new FlagEvaluator(false, (flag, subject, allocation, variant, serialId) -> false, mode); + } + + private static List evaluations(final FlagEvaluator.EvaluationResult result) { + final List values = new ArrayList<>(); + for (final FlagEvaluator.DependencyEvaluation evaluation : result.dependencyEvaluations) { + values.add( + evaluation.flagKey + + ":" + + (evaluation.errorCode == null ? evaluation.variant : evaluation.errorCode.name())); + } + return values; + } + + private static List exposures(final FlagEvaluator.EvaluationResult result) { + final List values = new ArrayList<>(); + for (final FlagEvaluator.DependencyExposure exposure : result.dependencyExposures) { + values.add(exposure.flagKey); + } + return values; + } + + private static ServerConfiguration config( + final Map flags, final EvaluatorParams params) { + return new ServerConfiguration("", "", false, null, flags, params); + } + + private static Flag stringFlag( + final String key, final String variant, final Rule rule, final boolean doLog) { + final Map variations = new HashMap<>(); + variations.put(variant, new Variant(variant, variant + "-value")); + return new Flag( + key, + true, + ValueType.STRING, + variations, + singletonList(allocation(key + "-allocation", rule, variant, doLog))); + } + + private static Flag integerFlag(final String key, final List allocations) { + final Map variations = new HashMap<>(); + variations.put("selected", new Variant("selected", 1)); + return new Flag(key, true, ValueType.INTEGER, variations, allocations); + } + + private static Allocation allocation( + final String key, final Rule rule, final String variant, final boolean doLog) { + final List rules = rule == null ? null : singletonList(rule); + return new Allocation( + key, + rules, + null, + null, + singletonList(new Split(emptyList(), variant, emptyMap(), 1)), + doLog); + } + + private static Rule dependencyRule(final String flagKey, final String expectedVariant) { + return new Rule( + singletonList( + new ConditionConfiguration(ONE_OF, null, flagKey, singletonList(expectedVariant)))); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java index 8a7af915e14..77925f26dad 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java @@ -119,13 +119,14 @@ void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { assertTrue(configuration.flags.containsKey("null-split")); assertTrue(configuration.flags.containsKey("no-rules")); assertTrue(configuration.flags.containsKey("no-conditions")); - assertTrue(configuration.flags.containsKey("no-operator")); + assertFalse(configuration.flags.containsKey("no-operator")); assertTrue(configuration.flags.containsKey("non-semver")); assertTrue(configuration.flags.containsKey("valid-semver")); assertFalse(configuration.flags.containsKey("invalid-semver")); assertFalse(configuration.flags.containsKey("non-string-semver")); assertFalse(configuration.flags.containsKey("null-flag")); - assertEquals(2, configuration.invalidFlags.size()); + assertEquals(3, configuration.invalidFlags.size()); + assertEquals("invalid_flag", configuration.invalidFlags.get("no-operator")); assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index 5afe68fa544..e8113e7936a 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -28,6 +28,90 @@ void parsesEmptyConfiguration() throws Exception { assertTrue(config.invalidFlags.isEmpty()); } + @Test + void parsesDependentFlagConditionsAndEvaluatorParams() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"evaluatorParams\":{\"maxDependencyDepth\":4},\"flags\":{\"root\":{" + + "\"key\":\"root\",\"allocations\":[{\"key\":\"a\",\"rules\":[{" + + "\"conditions\":[{\"operator\":\"ONE_OF\"," + + "\"flagEvaluation\":{\"key\":\"dependency\"},\"value\":[\"on\"]}]}]," + + "\"splits\":[]}]}}}"); + + assertEquals(4.0, config.evaluatorParams.maxDependencyDepth); + final ConditionConfiguration condition = + config.flags.get("root").allocations.get(0).rules.get(0).conditions.get(0); + assertNull(condition.attribute); + assertEquals("dependency", condition.flagEvaluationKey); + assertEquals(ConditionOperator.ONE_OF, condition.operator); + } + + @TableTest({ + "operator | values ", + "ONE_OF | '[true, 1]' ", + "NOT_ONE_OF | '[false, 2.5]'" + }) + void preservesBooleanAndNumericAttributeMembership( + final ConditionOperator operator, final String values) throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"target\":{\"key\":\"target\",\"allocations\":[{\"key\":\"a\"," + + "\"rules\":[{\"conditions\":[{\"operator\":\"" + + operator + + "\",\"attribute\":\"value\",\"value\":" + + values + + "}]}],\"splits\":[]}]}}}"); + + assertTrue(config.flags.containsKey("target")); + assertTrue(config.invalidFlags.isEmpty()); + } + + @TableTest({ + "operator | values ", + "ONE_OF | '[true]' ", + "NOT_ONE_OF | '[1, \"on\"]'" + }) + void rejectsNonStringDependentFlagMembership( + final ConditionOperator operator, final String values) throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"root\":{\"key\":\"root\",\"allocations\":[{\"key\":\"a\"," + + "\"rules\":[{\"conditions\":[{\"operator\":\"" + + operator + + "\",\"flagEvaluation\":{\"key\":\"dependency\"},\"value\":" + + values + + "}]}],\"splits\":[]}]}}}"); + + assertFalse(config.flags.containsKey("root")); + assertEquals("invalid_flag", config.invalidFlags.get("root")); + } + + @Test + void distinguishesAbsentEmptyAndExplicitNullMaximumDependencyDepth() throws Exception { + final ServerConfiguration absent = deserialize(emptyConfig()); + final ServerConfiguration empty = deserialize("{\"evaluatorParams\":{},\"flags\":{}}"); + final ServerConfiguration explicitNull = + deserialize("{\"evaluatorParams\":{\"maxDependencyDepth\":null},\"flags\":{}}"); + + assertNull(absent.evaluatorParams); + assertFalse(empty.evaluatorParams.maxDependencyDepthPresent); + assertTrue(explicitNull.evaluatorParams.maxDependencyDepthPresent); + assertNull(explicitNull.evaluatorParams.maxDependencyDepth); + } + + @Test + void rejectsConditionsWithBothAttributeAndFlagEvaluation() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"bad\":{\"key\":\"bad\",\"allocations\":[{\"key\":\"a\"," + + "\"rules\":[{\"conditions\":[{\"operator\":\"ONE_OF\"," + + "\"attribute\":\"plan\",\"flagEvaluation\":{\"key\":\"dependency\"}," + + "\"value\":[\"on\"]}]}],\"splits\":[]}]}}}"); + + assertFalse(config.flags.containsKey("bad")); + assertEquals("invalid_flag", config.invalidFlags.get("bad")); + } + @Test void parsesNullDocumentAsNoConfiguration() throws Exception { assertNull(deserialize("null")); diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index 8922955d373..df907bffd91 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -1,6 +1,7 @@ package com.datadog.featureflag; import static datadog.remoteconfig.Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES; +import static datadog.remoteconfig.Capabilities.CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1; import static datadog.remoteconfig.Product.FFE_FLAGS; import datadog.remoteconfig.ConfigurationChangesTypedListener; @@ -47,7 +48,9 @@ public synchronized void start() { } this.started = true; LOGGER.debug("Feature Flagging starting remote_config configuration source"); - this.configurationPoller.addCapabilities(CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + this.configurationPoller.addCapabilities( + CAPABILITY_FFE_FLAG_CONFIGURATION_RULES + | CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); listen(); this.configurationPoller.start(); } @@ -111,7 +114,9 @@ public synchronized void close() { } this.started = false; LOGGER.debug("Feature Flagging remote_config configuration source stopped"); - this.configurationPoller.removeCapabilities(CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + this.configurationPoller.removeCapabilities( + CAPABILITY_FFE_FLAG_CONFIGURATION_RULES + | CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); this.configurationPoller.removeListeners(FFE_FLAGS); this.configurationPoller.stop(); this.subscribers.clear(); 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 f4083331c6c..55698f56921 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 @@ -54,7 +54,8 @@ void subscriptionRegistersTheProductWithARawDeserializer() throws Exception { service.subscribe(content -> {}); service.subscribe(content -> {}); - verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller) + .addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES | (1L << 50)); final ArgumentCaptor> deserializer = ArgumentCaptor.forClass(ConfigurationDeserializer.class); verify(poller).addListener(eq(Product.FFE_FLAGS), deserializer.capture(), eq(service)); @@ -258,7 +259,8 @@ void startRegistersTheProductOnceBeforeAnySubscription() { service.start(); service.subscribe(content -> {}); - verify(poller, times(1)).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller, times(1)) + .addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES | (1L << 50)); verify(poller, times(1)).start(); } @@ -288,7 +290,7 @@ void closeUnregistersTheProductAndDropsSubscribers() { service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); verify(poller, times(1)) - .removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + .removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES | (1L << 50)); verify(poller, times(1)).removeListeners(Product.FFE_FLAGS); verify(poller, times(1)).stop(); assertEquals(0, received.size()); diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java index ce76e59000a..d0956d08364 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java @@ -47,4 +47,5 @@ public interface Capabilities { long CAPABILITY_ASM_EXTENDED_DATA_COLLECTION = 1L << 44; long CAPABILITY_APM_TRACING_MULTICONFIG = 1L << 45; long CAPABILITY_FFE_FLAG_CONFIGURATION_RULES = 1L << 46; + long CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1 = 1L << 50; }