From 28c18d8f497d64da3c011cd7f345dc531c337c17 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 29 Oct 2026 06:41:34 -0600 Subject: [PATCH 01/12] feat(openfeature): evaluate dependent flags Add strict dependent-flag evaluation, ordered telemetry and transactional exposures, and the agentless capability header. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../com/datadog/openfeature/DDEvaluator.java | 202 +++++- .../openfeature/ExposureLoggingHook.java | 23 +- .../com/datadog/openfeature/Provider.java | 20 +- .../delivery/CdnConfigurationSource.java | 5 +- .../internal/evaluation/FlagEvaluator.java | 585 +++++++++++++++--- .../internal/ufc/ConditionConfiguration.java | 10 + .../internal/ufc/EvaluatorParams.java | 16 + .../internal/ufc/ServerConfiguration.java | 12 + .../ufc/UniversalFlagConfigParser.java | 53 +- .../EvaluationTelemetryOptOutTest.java | 103 ++- .../delivery/CdnConfigurationSourceTest.java | 3 + .../DependentFlagEvaluatorTest.java | 405 ++++++++++++ .../UniversalFlagConfigParserJsonApiTest.java | 5 +- .../ufc/UniversalFlagConfigParserTest.java | 44 ++ 14 files changed, 1369 insertions(+), 117 deletions(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/EvaluatorParams.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorTest.java 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 8c6ced76c65..8d1066862ac 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; @@ -27,11 +38,16 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; class DDEvaluator implements Evaluator, Consumer { + private static final String DEPENDENCY_EXPOSURES_TOKEN = "__dd_dependency_exposures_token"; + private static final String EVALUATION_TELEMETRY_OPT_OUT = "dd_evaluation_telemetry_opt_out"; + /** * Maximum evaluation-context nesting depth captured on the hot path. Recursion runs on the * caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep @@ -81,7 +97,14 @@ class DDEvaluator implements Evaluator, Consumer { private final Settings settings; private final FlagEvaluator core; private final ConfigurationStore configuration = new ConfigurationStore(); + private final ThreadLocal> stagedDependencyExposures = + new ThreadLocal<>(); + private final AtomicLong nextDependencyExposureToken = new AtomicLong(); + private final Map> pendingDependencyExposures = + new ConcurrentHashMap<>(); private volatile FeatureFlagsRuntime runtime; + private volatile boolean dependencyTelemetryOptOutEnabled; + private volatile FlagEvalMetrics dependencyMetrics; DDEvaluator(final Runnable configCallback, final AgentBridge bridge, final Settings settings) { this.configCallback = configCallback; @@ -89,7 +112,17 @@ class DDEvaluator implements Evaluator, Consumer { this.settings = settings; // When span enrichment is off, the __dd_* span-enrichment metadata is attached only to // evaluations of allocations logging exposures, for the exposure hook. - this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::isExposed); + this.core = + new FlagEvaluator( + settings.isSpanEnrichmentEnabled(), + this::isExposed, + this::recordDependencyEvaluation, + (items, context) -> stagedDependencyExposures.set(items)); + } + + void configureDependencyTelemetry(final boolean optOutEnabled, final FlagEvalMetrics metrics) { + this.dependencyTelemetryOptOutEnabled = optOutEnabled; + this.dependencyMetrics = metrics; } @Override @@ -147,7 +180,19 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { - return core.evaluate(configuration.get(), target, key, defaultValue, context); + stagedDependencyExposures.remove(); + final ProviderEvaluation result = + core.evaluate(configuration.get(), target, key, defaultValue, context); + final List staged = stagedDependencyExposures.get(); + stagedDependencyExposures.remove(); + if (staged == null || staged.isEmpty() || result.getErrorCode() != null) { + return result; + } + final long token = nextDependencyExposureToken.incrementAndGet(); + pendingDependencyExposures.put(token, staged); + result.setFlagMetadata( + withLongMetadata(result.getFlagMetadata(), DEPENDENCY_EXPOSURES_TOKEN, token)); + return result; } static boolean isAllocationActive(final Allocation allocation, final Instant now) { @@ -232,6 +277,159 @@ void recordExposure(final ExposureEvent event) { } } + private void recordDependencyEvaluation( + final String flagKey, + final ProviderEvaluation details, + final EvaluationContext context, + final long evaluationTimestampMs) { + if (dependencyTelemetryOptOutEnabled && isEvaluationTelemetryOptedOut(context)) { + return; + } + final ImmutableMetadata metadata = details.getFlagMetadata(); + 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; + final FlagEvalMetrics metrics = dependencyMetrics; + if (metrics != null) { + metrics.record( + flagKey, + details.getVariant(), + details.getReason(), + details.getErrorCode(), + allocationKey); + } + final FeatureFlagsRuntime current = runtime(); + final FlagEvaluationWriter writer = current == null ? null : current.evaluations(); + 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 ? details.getErrorMessage() : null; + if ((errorMessage == null || errorMessage.isEmpty()) && details.getErrorCode() != null) { + errorMessage = details.getErrorCode().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( + flagKey, + details.getVariant(), + allocationKey, + context == null ? null : context.getTargetingKey(), + errorMessage, + evaluationTimestampMs, + observeFullEvaluationData, + attrs, + features)); + } + + List takeDependencyExposures(final ImmutableMetadata metadata) { + if (metadata == null) { + return Collections.emptyList(); + } + final Object token = metadata.asUnmodifiableMap().get(DEPENDENCY_EXPOSURES_TOKEN); + if (!(token instanceof Long)) { + return Collections.emptyList(); + } + final List exposures = + pendingDependencyExposures.remove((Long) token); + return exposures == null + ? Collections.emptyList() + : exposures; + } + + 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 ProviderEvaluation details = exposure.details; + final ImmutableMetadata metadata = details.getFlagMetadata(); + 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 = details.getVariant(); + 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))); + } + } + } + + private static boolean isEvaluationTelemetryOptedOut(final EvaluationContext context) { + final Value value = context == null ? null : context.getValue(EVALUATION_TELEMETRY_OPT_OUT); + return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean()); + } + + private static ImmutableMetadata withLongMetadata( + final ImmutableMetadata metadata, final String key, final long value) { + final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + if (metadata != null) { + for (final Map.Entry entry : metadata.asUnmodifiableMap().entrySet()) { + addMetadata(builder, entry.getKey(), entry.getValue()); + } + } + builder.addLong(key, value); + return builder.build(); + } + + private static void addMetadata( + final ImmutableMetadata.ImmutableMetadataBuilder builder, + final String key, + final Object value) { + if (value instanceof String) { + builder.addString(key, (String) value); + } else if (value instanceof Boolean) { + builder.addBoolean(key, (Boolean) value); + } else if (value instanceof Integer) { + builder.addInteger(key, (Integer) value); + } else if (value instanceof Long) { + builder.addLong(key, (Long) value); + } else if (value instanceof Float) { + builder.addFloat(key, (Float) value); + } else if (value instanceof Double) { + builder.addDouble(key, (Double) value); + } + } + 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..a9329e187a7 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -12,6 +12,8 @@ import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Reason; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; @@ -46,10 +48,27 @@ public void finallyAfter( final FlagEvaluationDetails details, final Map hints) { try { - if (details == null || details.getErrorCode() != null) { + if (details == null) { log.trace("Feature Flagging exposure suppressed: evaluation failed or details unavailable"); return; } + final DDEvaluator evaluator = this.evaluatorSupplier.get(); + final List + dependencyExposures = + evaluator == null + ? java.util.Collections + . + emptyList() + : evaluator.takeDependencyExposures(details.getFlagMetadata()); + 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) { + evaluator.recordDependencyExposures(dependencyExposures, context); + } final ImmutableMetadata metadata = details.getFlagMetadata(); if (metadata == null) { log.trace("Feature Flagging exposure suppressed: metadata unavailable"); @@ -69,13 +88,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"); 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 70ac3436b8c..980918d8706 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -43,6 +43,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 @@ -90,6 +91,9 @@ public Provider(final Options options) { } this.flagEvalMetrics = metrics; this.flagEvalMetricsHook = hook; + this.evaluationTelemetryOptOutEnabled = + options.isEvaluationTelemetryOptOutParameterEnabled(this.settings); + configureDependencyTelemetry(evaluator); // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle // per-evaluation overhead. @@ -112,7 +116,7 @@ public Provider(final Options options) { 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))); } @@ -279,9 +283,21 @@ private void markInitializationError() { private Evaluator buildEvaluator() { if (evaluator != null) { + configureDependencyTelemetry(evaluator); return evaluator; } - return new DDEvaluator(this::onConfigurationChange, this.bridge, this.settings); + final DDEvaluator built = + new DDEvaluator(this::onConfigurationChange, this.bridge, this.settings); + configureDependencyTelemetry(built); + return built; + } + + private void configureDependencyTelemetry(final Evaluator candidate) { + if (candidate instanceof DDEvaluator) { + ((DDEvaluator) candidate) + .configureDependencyTelemetry( + this.evaluationTelemetryOptOutEnabled, this.flagEvalMetrics); + } } 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 dd4b4956492..b8a389b3c20 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 @@ -35,8 +35,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; @@ -53,12 +56,15 @@ 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; static final Set WARNED_FEATURE_DESTINATIONS = ConcurrentHashMap.newKeySet(); private final boolean spanEnrichmentEnabled; private final ExposureLookup exposureLookup; + private final DependencyEvaluationSink dependencyEvaluationSink; + private final DependencyExposureSink dependencyExposureSink; /** * Creates an evaluator. @@ -67,8 +73,22 @@ public final class FlagEvaluator { * @param exposureLookup the lookup telling whether an exposure was already recorded. */ public FlagEvaluator(final boolean spanEnrichmentEnabled, final ExposureLookup exposureLookup) { + this( + spanEnrichmentEnabled, + exposureLookup, + (flag, details, context, timestamp) -> {}, + (items, context) -> {}); + } + + public FlagEvaluator( + final boolean spanEnrichmentEnabled, + final ExposureLookup exposureLookup, + final DependencyEvaluationSink dependencyEvaluationSink, + final DependencyExposureSink dependencyExposureSink) { this.spanEnrichmentEnabled = spanEnrichmentEnabled; this.exposureLookup = exposureLookup; + this.dependencyEvaluationSink = dependencyEvaluationSink; + this.dependencyExposureSink = dependencyExposureSink; } public ProviderEvaluation evaluate( @@ -84,114 +104,131 @@ 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 EvaluationState state = + new EvaluationState(config, context, observeFullEvaluationData, System.currentTimeMillis()); try { - if (config == null) { - return error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData); - } - - if (context == null) { - return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); - } - - final Flag flag = config.flags.get(key); - if (flag == null) { - if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { - return error( - defaultValue, - ErrorCode.PARSE_ERROR, - "invalid configuration for flag " + key, - observeFullEvaluationData); - } - return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); - } - - if (!flag.enabled) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DISABLED.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } + final ProviderEvaluation result = evaluateFlag(state, target, key, defaultValue); + state.complete(result.getErrorCode() == null); + return result; + } catch (final DependencyGraphException e) { + state.complete(false); + return error(defaultValue, e.errorCode, e.getMessage(), observeFullEvaluationData); + } catch (final PatternSyntaxException e) { + state.complete(false); + return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); + } catch (final NumberFormatException e) { + state.complete(false); + return error( + defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); + } catch (final Exception e) { + state.complete(false); + return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); + } + } - if (flag.allocations == null) { + 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.GENERAL, - "Missing allocations for flag " + key, - observeFullEvaluationData); + ErrorCode.PARSE_ERROR, + "invalid configuration for flag " + key, + state.observeFullEvaluationData); } + return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, 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); + } - final Instant now = Instant.now(); - final long evalTimestampMs = now.toEpochMilli(); - final String targetingKey = context.getTargetingKey(); - - for (final Allocation allocation : flag.allocations) { - if (!isAllocationActive(allocation, now)) { - continue; - } - - if (!isEmpty(allocation.rules)) { - if (!evaluateRules(allocation.rules, context)) { - continue; - } - } - - if (!isEmpty(allocation.splits)) { - for (final Split split : allocation.splits) { - if (isEmpty(split.shards)) { - return resolveVariant( - target, + final Instant now = Instant.ofEpochMilli(state.evaluationTimestampMs); + final String targetingKey = state.context.getTargetingKey(); + for (final Allocation allocation : flag.allocations) { + if (!isAllocationActive(allocation, now)) { + continue; + } + 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) { @@ -237,15 +274,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; } @@ -254,21 +296,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; } @@ -623,6 +674,338 @@ 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 DependencyEvaluationSession session; + + private EvaluationState( + final ServerConfiguration config, + final EvaluationContext context, + final boolean observeFullEvaluationData, + final long evaluationTimestampMs) { + this.config = config; + this.context = context; + this.observeFullEvaluationData = observeFullEvaluationData; + this.evaluationTimestampMs = evaluationTimestampMs; + } + + 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 void complete(final boolean commitExposures) { + if (session != null) { + session.complete(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 List exposures = new ArrayList<>(); + private boolean completed; + + 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 void complete(final boolean commitExposures) { + if (completed) { + return; + } + completed = true; + if (commitExposures && !exposures.isEmpty()) { + try { + dependencyExposureSink.record(new ArrayList<>(exposures), state.context); + } catch (final RuntimeException | LinkageError ignored) { + // Telemetry is best effort and must not change evaluation semantics. + } + } + } + + 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) { + try { + dependencyEvaluationSink.record( + flagKey, details, state.context, state.evaluationTimestampMs); + } catch (final RuntimeException | LinkageError ignored) { + // Telemetry is best effort and must not change evaluation semantics. + } + } + + 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 ProviderEvaluation details; + + private DependencyExposure(final String flagKey, final ProviderEvaluation details) { + this.flagKey = flagKey; + this.details = details; + } + } + + @FunctionalInterface + public interface DependencyEvaluationSink { + void record( + String flagKey, + ProviderEvaluation details, + EvaluationContext context, + long evaluationTimestampMs); + } + + @FunctionalInterface + public interface DependencyExposureSink { + void record(List exposures, EvaluationContext context); + } + /** 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 c5d79cd67e1..74eb4d959da 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 @@ -3,6 +3,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 @@ -11,8 +12,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 2721be5bfef..dadf8f63456 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) { @@ -260,10 +271,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 @@ -551,6 +588,12 @@ private static void validateConditionOperands(final String flagKey, final Alloca throw new IllegalArgumentException( "flag \"" + flagKey + "\" has a non-list condition"); } + 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 b85b8dff282..788840113b7 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -508,6 +508,60 @@ 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(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()); + } + + @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 concurrentSubjectsCannotChangeEachOthersDecision() throws Exception { start(); @@ -604,7 +658,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(); } @@ -651,6 +710,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 = 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..1f8d24dd544 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorTest.java @@ -0,0 +1,405 @@ +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.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.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 memoizesDependenciesAndReplaysTheirExposureAfterRuleRollback() { + final List evaluations = new ArrayList<>(); + final List exposures = new ArrayList<>(); + final FlagEvaluator evaluator = + evaluator( + evaluations, + (items, context) -> { + for (final FlagEvaluator.DependencyExposure item : items) { + exposures.add(item.flagKey); + } + }); + 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 ProviderEvaluation result = + evaluator.evaluate(config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + + assertEquals(1, result.getValue()); + assertEquals(singletonList("dependency:on"), evaluations); + assertEquals(singletonList("dependency"), exposures); + } + + @Test + void rollsBackDependencyExposuresWhenTheRootEvaluationFails() { + final List evaluations = new ArrayList<>(); + final List> exposureCommits = new ArrayList<>(); + final FlagEvaluator evaluator = + evaluator(evaluations, (items, context) -> exposureCommits.add(items)); + 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 ProviderEvaluation result = + evaluator.evaluate(config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals(singletonList("dependency:on"), evaluations); + assertTrue(exposureCommits.isEmpty()); + } + + @Test + void propagatesCyclesAndEmitsAnErrorForEveryReachedDependency() { + final List evaluations = new ArrayList<>(); + final FlagEvaluator evaluator = evaluator(evaluations, (items, context) -> {}); + 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 ProviderEvaluation result = + evaluator.evaluate( + config(flags, null), String.class, "a", "default", new MutableContext("u")); + + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals(singletonList("b:GENERAL"), evaluations); + } + + @Test + void enforcesTheConfiguredMaximumDependencyDepth() { + final List evaluations = new ArrayList<>(); + final FlagEvaluator evaluator = evaluator(evaluations, (items, context) -> {}); + 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 ProviderEvaluation result = + evaluator.evaluate( + config(flags, new EvaluatorParams(1D)), + String.class, + "root", + "default", + new MutableContext("u")); + + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals(Arrays.asList("b:GENERAL", "a:GENERAL"), evaluations); + } + + @Test + void memoizedSubtreeDepthFailureDoesNotReemitMemoizedNodeInEitherOrder() { + for (final List rootDependencies : + Arrays.asList(Arrays.asList("a", "b"), Arrays.asList("b", "a"))) { + final List evaluations = new ArrayList<>(); + final List exposures = new ArrayList<>(); + final FlagEvaluator evaluator = + evaluator( + evaluations, + (items, context) -> { + for (final FlagEvaluator.DependencyExposure item : items) { + exposures.add(item.flagKey); + } + }); + 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 ProviderEvaluation result = + evaluator.evaluate( + config(flags, new EvaluatorParams(2D)), + String.class, + "root", + "default", + new MutableContext("u")); + + assertEquals(ErrorCode.GENERAL, result.getErrorCode(), rootDependencies.toString()); + final List evaluationKeys = new ArrayList<>(); + for (final String evaluation : evaluations) { + evaluationKeys.add(evaluation.substring(0, evaluation.indexOf(':'))); + } + assertEquals(Arrays.asList("leaf", "a", "b"), evaluationKeys); + assertTrue(exposures.isEmpty()); + } + } + + @Test + void validatesInvalidMaximumDependencyDepthOnlyWhenADependencyIsReached() { + final FlagEvaluator evaluator = evaluator(new ArrayList<>(), (items, context) -> {}); + 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(new ArrayList<>(), (items, context) -> {}); + + 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(new ArrayList<>(), (items, context) -> {}) + .evaluate(config, String.class, "root", "default", new MutableContext("u")); + + assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode()); + } + + @Test + void supportsNotOneOfAndShortCircuitsBeforeAFatalDependency() { + final List evaluations = new ArrayList<>(); + final FlagEvaluator evaluator = evaluator(evaluations, (items, context) -> {}); + 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 ProviderEvaluation result = + evaluator.evaluate(config(flags, null), Integer.class, "root", 0, context); + + assertEquals(1, result.getValue()); + assertEquals(singletonList("dependency:on"), evaluations); + } + + @Test + void emitsMissingAndInvalidPrerequisiteErrors() { + final List missing = new ArrayList<>(); + final Map missingFlags = new HashMap<>(); + missingFlags.put("root", stringFlag("root", "on", dependencyRule("missing", "on"), false)); + assertEquals( + ErrorCode.FLAG_NOT_FOUND, + evaluator(missing, (items, context) -> {}) + .evaluate( + config(missingFlags, null), + String.class, + "root", + "default", + new MutableContext("u")) + .getErrorCode()); + assertEquals(singletonList("missing:FLAG_NOT_FOUND"), missing); + + final List invalid = new ArrayList<>(); + final ServerConfiguration invalidConfig = config(missingFlags, null); + invalidConfig.invalidFlags = singletonMap("missing", "invalid_flag"); + assertEquals( + ErrorCode.PARSE_ERROR, + evaluator(invalid, (items, context) -> {}) + .evaluate(invalidConfig, String.class, "root", "default", new MutableContext("u")) + .getErrorCode()); + assertEquals(singletonList("missing:PARSE_ERROR"), invalid); + } + + @Test + void rollsBackATransitiveTraceWhenTheRootAllocationHasNoAssignment() { + final List evaluations = new ArrayList<>(); + final List exposures = new ArrayList<>(); + final FlagEvaluator evaluator = + evaluator( + evaluations, + (items, context) -> { + for (final FlagEvaluator.DependencyExposure item : items) { + exposures.add(item.flagKey); + } + }); + 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 ProviderEvaluation result = + evaluator.evaluate(config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + + assertEquals(1, result.getValue()); + assertEquals(Arrays.asList("leaf:on", "parent:on"), evaluations); + assertTrue(exposures.isEmpty()); + } + + private static FlagEvaluator evaluator( + final List evaluations, final FlagEvaluator.DependencyExposureSink exposureSink) { + return new FlagEvaluator( + false, + (flag, subject, allocation, variant, serialId) -> false, + (flag, details, context, timestamp) -> + evaluations.add( + flag + + ":" + + (details.getErrorCode() == null + ? details.getVariant() + : details.getErrorCode().name())), + exposureSink); + } + + 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..7d56ff88616 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,50 @@ 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); + } + + @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")); From 9a329ce5b5545765058800b9a642c785a78856ae Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 2 Nov 2026 10:27:26 -0700 Subject: [PATCH 02/12] refactor(openfeature): return dependency telemetry batches Publish detached prerequisite evaluations only after graph completion while keeping dependency exposures transactional. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../com/datadog/openfeature/DDEvaluator.java | 55 +++--- .../internal/evaluation/FlagEvaluator.java | 150 +++++++++------ .../EvaluationTelemetryOptOutTest.java | 74 ++++++++ .../DependentFlagEvaluatorTest.java | 177 ++++++++---------- 4 files changed, 267 insertions(+), 189 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index 8d1066862ac..3d851d58f0e 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -97,8 +97,6 @@ class DDEvaluator implements Evaluator, Consumer { private final Settings settings; private final FlagEvaluator core; private final ConfigurationStore configuration = new ConfigurationStore(); - private final ThreadLocal> stagedDependencyExposures = - new ThreadLocal<>(); private final AtomicLong nextDependencyExposureToken = new AtomicLong(); private final Map> pendingDependencyExposures = new ConcurrentHashMap<>(); @@ -112,12 +110,7 @@ class DDEvaluator implements Evaluator, Consumer { this.settings = settings; // When span enrichment is off, the __dd_* span-enrichment metadata is attached only to // evaluations of allocations logging exposures, for the exposure hook. - this.core = - new FlagEvaluator( - settings.isSpanEnrichmentEnabled(), - this::isExposed, - this::recordDependencyEvaluation, - (items, context) -> stagedDependencyExposures.set(items)); + this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::isExposed); } void configureDependencyTelemetry(final boolean optOutEnabled, final FlagEvalMetrics metrics) { @@ -180,11 +173,17 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { - stagedDependencyExposures.remove(); - final ProviderEvaluation result = - core.evaluate(configuration.get(), target, key, defaultValue, context); - final List staged = stagedDependencyExposures.get(); - stagedDependencyExposures.remove(); + final FlagEvaluator.EvaluationResult evaluation = + core.evaluateWithDependencies(configuration.get(), target, key, defaultValue, context); + for (final FlagEvaluator.DependencyEvaluation dependency : evaluation.dependencyEvaluations) { + try { + publishDependencyEvaluation(dependency, context, evaluation.evaluationTimestampMs); + } catch (final RuntimeException | LinkageError ignored) { + // Telemetry is best effort and must not change the root evaluation. + } + } + final ProviderEvaluation result = evaluation.details; + final List staged = evaluation.dependencyExposures; if (staged == null || staged.isEmpty() || result.getErrorCode() != null) { return result; } @@ -277,15 +276,14 @@ void recordExposure(final ExposureEvent event) { } } - private void recordDependencyEvaluation( - final String flagKey, - final ProviderEvaluation details, + void publishDependencyEvaluation( + final FlagEvaluator.DependencyEvaluation dependency, final EvaluationContext context, final long evaluationTimestampMs) { if (dependencyTelemetryOptOutEnabled && isEvaluationTelemetryOptedOut(context)) { return; } - final ImmutableMetadata metadata = details.getFlagMetadata(); + final ImmutableMetadata metadata = dependency.metadata; final Map values = metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); final Object allocationValue = values.get(ALLOCATION_KEY); @@ -294,10 +292,10 @@ private void recordDependencyEvaluation( final FlagEvalMetrics metrics = dependencyMetrics; if (metrics != null) { metrics.record( - flagKey, - details.getVariant(), - details.getReason(), - details.getErrorCode(), + dependency.flagKey, + dependency.variant, + dependency.reason, + dependency.errorCode, allocationKey); } final FeatureFlagsRuntime current = runtime(); @@ -310,9 +308,9 @@ private void recordDependencyEvaluation( } final boolean observeFullEvaluationData = Boolean.TRUE.equals(values.get(OBSERVE_FULL_EVALUATION_DATA)); - String errorMessage = observeFullEvaluationData ? details.getErrorMessage() : null; - if ((errorMessage == null || errorMessage.isEmpty()) && details.getErrorCode() != null) { - errorMessage = details.getErrorCode().name(); + String errorMessage = observeFullEvaluationData ? dependency.errorMessage : null; + if ((errorMessage == null || errorMessage.isEmpty()) && dependency.errorCode != null) { + errorMessage = dependency.errorCode.name(); } final Map attrs; if (observeFullEvaluationData) { @@ -330,8 +328,8 @@ private void recordDependencyEvaluation( : Collections.emptyMap(); writer.enqueue( new FlagEvalEvent( - flagKey, - details.getVariant(), + dependency.flagKey, + dependency.variant, allocationKey, context == null ? null : context.getTargetingKey(), errorMessage, @@ -363,8 +361,7 @@ void recordDependencyExposures( return; } for (final FlagEvaluator.DependencyExposure exposure : exposures) { - final ProviderEvaluation details = exposure.details; - final ImmutableMetadata metadata = details.getFlagMetadata(); + final ImmutableMetadata metadata = exposure.metadata; final Map values = metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); if (!Boolean.TRUE.equals(values.get(DO_LOG))) { @@ -373,7 +370,7 @@ void recordDependencyExposures( final Object allocationValue = values.get(ALLOCATION_KEY); final String allocationKey = allocationValue instanceof String ? (String) allocationValue : null; - final String variantKey = details.getVariant(); + final String variantKey = exposure.variant; if (allocationKey == null || variantKey == null) { continue; } 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 b8a389b3c20..cbbced47bc5 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.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ConditionConfiguration; @@ -63,8 +65,6 @@ public final class FlagEvaluator { static final Set WARNED_FEATURE_DESTINATIONS = ConcurrentHashMap.newKeySet(); private final boolean spanEnrichmentEnabled; private final ExposureLookup exposureLookup; - private final DependencyEvaluationSink dependencyEvaluationSink; - private final DependencyExposureSink dependencyExposureSink; /** * Creates an evaluator. @@ -73,22 +73,8 @@ public final class FlagEvaluator { * @param exposureLookup the lookup telling whether an exposure was already recorded. */ public FlagEvaluator(final boolean spanEnrichmentEnabled, final ExposureLookup exposureLookup) { - this( - spanEnrichmentEnabled, - exposureLookup, - (flag, details, context, timestamp) -> {}, - (items, context) -> {}); - } - - public FlagEvaluator( - final boolean spanEnrichmentEnabled, - final ExposureLookup exposureLookup, - final DependencyEvaluationSink dependencyEvaluationSink, - final DependencyExposureSink dependencyExposureSink) { this.spanEnrichmentEnabled = spanEnrichmentEnabled; this.exposureLookup = exposureLookup; - this.dependencyEvaluationSink = dependencyEvaluationSink; - this.dependencyExposureSink = dependencyExposureSink; } public ProviderEvaluation evaluate( @@ -97,6 +83,16 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { + return evaluateWithDependencies(config, target, key, defaultValue, context).details; + } + + /** Evaluates a root flag and returns all telemetry candidates produced by its graph. */ + public EvaluationResult evaluateWithDependencies( + 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. @@ -106,24 +102,27 @@ public ProviderEvaluation evaluate( config != null && Boolean.TRUE.equals(config.observeFullEvaluationData); final EvaluationState state = new EvaluationState(config, context, observeFullEvaluationData, System.currentTimeMillis()); + ProviderEvaluation result; + boolean commitExposures; try { - final ProviderEvaluation result = evaluateFlag(state, target, key, defaultValue); - state.complete(result.getErrorCode() == null); - return result; + result = evaluateFlag(state, target, key, defaultValue); + commitExposures = result.getErrorCode() == null; } catch (final DependencyGraphException e) { - state.complete(false); - return error(defaultValue, e.errorCode, e.getMessage(), observeFullEvaluationData); + result = error(defaultValue, e.errorCode, e.getMessage(), observeFullEvaluationData); + commitExposures = false; } catch (final PatternSyntaxException e) { - state.complete(false); - return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); + result = + error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); + commitExposures = false; } catch (final NumberFormatException e) { - state.complete(false); - return error( - defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); + result = + error(defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); + commitExposures = false; } catch (final Exception e) { - state.complete(false); - return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); + result = error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); + commitExposures = false; } + return state.complete(result, commitExposures); } private ProviderEvaluation evaluateFlag( @@ -709,10 +708,11 @@ private void rollback(final ExposureCheckpoint checkpoint) { } } - private void complete(final boolean commitExposures) { - if (session != null) { - session.complete(commitExposures); - } + private EvaluationResult complete( + final ProviderEvaluation details, final boolean commitExposures) { + return session == null + ? new EvaluationResult<>(details, emptyList(), emptyList(), evaluationTimestampMs) + : session.complete(details, commitExposures); } } @@ -722,8 +722,9 @@ private final class DependencyEvaluationSession { 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 boolean completed; private DependencyEvaluationSession( final String rootFlagKey, final int maxDependencyDepth, final EvaluationState state) { @@ -750,18 +751,13 @@ private void rollback(final ExposureCheckpoint checkpoint) { } } - private void complete(final boolean commitExposures) { - if (completed) { - return; - } - completed = true; - if (commitExposures && !exposures.isEmpty()) { - try { - dependencyExposureSink.record(new ArrayList<>(exposures), state.context); - } catch (final RuntimeException | LinkageError ignored) { - // Telemetry is best effort and must not change evaluation semantics. - } - } + 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) { @@ -862,12 +858,10 @@ private DependencyGraphException emitFailure( } private void emitEvaluation(final String flagKey, final ProviderEvaluation details) { - try { - dependencyEvaluationSink.record( - flagKey, details, state.context, state.evaluationTimestampMs); - } catch (final RuntimeException | LinkageError ignored) { - // Telemetry is best effort and must not change evaluation semantics. + if (!evaluatedFlags.add(flagKey)) { + return; } + evaluations.add(new DependencyEvaluation(flagKey, details)); } private void recordReachedDepth(final int depth) { @@ -984,26 +978,58 @@ private DependencyGraphException(final ErrorCode errorCode, final String message public static final class DependencyExposure { public final String flagKey; - public final ProviderEvaluation details; + public final String variant; + public final ImmutableMetadata metadata; private DependencyExposure(final String flagKey, final ProviderEvaluation details) { this.flagKey = flagKey; - this.details = details; + this.variant = details.getVariant(); + this.metadata = details.getFlagMetadata(); } } - @FunctionalInterface - public interface DependencyEvaluationSink { - void record( - String flagKey, - ProviderEvaluation details, - EvaluationContext context, - long evaluationTimestampMs); + /** 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(); + } } - @FunctionalInterface - public interface DependencyExposureSink { - void record(List exposures, EvaluationContext context); + /** 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. */ 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 788840113b7..bc5095d1071 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -24,6 +24,7 @@ import com.datadog.openfeature.internal.bridge.v1.HealthMetrics; import com.datadog.openfeature.internal.bridge.v1.SpanEnricher; import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.evaluation.FlagEvaluator; import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.exposure.ExposurePipeline; @@ -113,6 +114,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 { @@ -533,6 +536,8 @@ void dependencyTelemetryIncludesMetricsWhenProviderOptOutIsDisabled() throws Exc 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()); @@ -562,6 +567,61 @@ public void after( 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(); @@ -842,6 +902,20 @@ boolean isExposureDeliveryEnabled() { void recordExposure(final ExposureEvent event) { EvaluationTelemetryOptOutTest.this.exposures.add(event); } + + @Override + void publishDependencyEvaluation( + final FlagEvaluator.DependencyEvaluation dependency, + final EvaluationContext context, + final long evaluationTimestampMs) { + if (EvaluationTelemetryOptOutTest.this.failDependencyPublisher) { + throw new IllegalStateException("publisher failed"); + } + if (EvaluationTelemetryOptOutTest.this.mutateDependencyRecord) { + dependency.metadata.asUnmodifiableMap().clear(); + } + super.publishDependencyEvaluation(dependency, context, evaluationTimestampMs); + } } /** A flag evaluation pipeline capturing enqueued events, never started. */ 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 index 1f8d24dd544..042dd1725bf 100644 --- 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 @@ -35,16 +35,7 @@ class DependentFlagEvaluatorTest { @Test void memoizesDependenciesAndReplaysTheirExposureAfterRuleRollback() { - final List evaluations = new ArrayList<>(); - final List exposures = new ArrayList<>(); - final FlagEvaluator evaluator = - evaluator( - evaluations, - (items, context) -> { - for (final FlagEvaluator.DependencyExposure item : items) { - exposures.add(item.flagKey); - } - }); + final FlagEvaluator evaluator = evaluator(); final Map flags = new HashMap<>(); flags.put("dependency", stringFlag("dependency", "on", null, true)); flags.put( @@ -55,20 +46,19 @@ void memoizesDependenciesAndReplaysTheirExposureAfterRuleRollback() { allocation("rejected", dependencyRule("dependency", "off"), "selected", false), allocation("accepted", dependencyRule("dependency", "on"), "selected", false)))); - final ProviderEvaluation result = - evaluator.evaluate(config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithDependencies( + config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + final ProviderEvaluation result = evaluation.details; assertEquals(1, result.getValue()); - assertEquals(singletonList("dependency:on"), evaluations); - assertEquals(singletonList("dependency"), exposures); + assertEquals(singletonList("dependency:on"), evaluations(evaluation)); + assertEquals(singletonList("dependency"), exposures(evaluation)); } @Test void rollsBackDependencyExposuresWhenTheRootEvaluationFails() { - final List evaluations = new ArrayList<>(); - final List> exposureCommits = new ArrayList<>(); - final FlagEvaluator evaluator = - evaluator(evaluations, (items, context) -> exposureCommits.add(items)); + final FlagEvaluator evaluator = evaluator(); final Map flags = new HashMap<>(); flags.put("dependency", stringFlag("dependency", "on", null, true)); flags.put( @@ -78,65 +68,58 @@ void rollsBackDependencyExposuresWhenTheRootEvaluationFails() { singletonList( allocation("broken", dependencyRule("dependency", "on"), "missing", false)))); - final ProviderEvaluation result = - evaluator.evaluate(config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithDependencies( + 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); - assertTrue(exposureCommits.isEmpty()); + assertEquals(singletonList("dependency:on"), evaluations(evaluation)); + assertTrue(evaluation.dependencyExposures.isEmpty()); } @Test void propagatesCyclesAndEmitsAnErrorForEveryReachedDependency() { - final List evaluations = new ArrayList<>(); - final FlagEvaluator evaluator = evaluator(evaluations, (items, context) -> {}); + 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 ProviderEvaluation result = - evaluator.evaluate( + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithDependencies( 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); + assertEquals(singletonList("b:GENERAL"), evaluations(evaluation)); } @Test void enforcesTheConfiguredMaximumDependencyDepth() { - final List evaluations = new ArrayList<>(); - final FlagEvaluator evaluator = evaluator(evaluations, (items, context) -> {}); + 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 ProviderEvaluation result = - evaluator.evaluate( + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithDependencies( 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); + 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 List evaluations = new ArrayList<>(); - final List exposures = new ArrayList<>(); - final FlagEvaluator evaluator = - evaluator( - evaluations, - (items, context) -> { - for (final FlagEvaluator.DependencyExposure item : items) { - exposures.add(item.flagKey); - } - }); + 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)); @@ -150,27 +133,28 @@ void memoizedSubtreeDepthFailureDoesNotReemitMemoizedNodeInEitherOrder() { ONE_OF, null, rootDependencies.get(1), singletonList("on")))); flags.put("root", stringFlag("root", "on", rootRule, true)); - final ProviderEvaluation result = - evaluator.evaluate( + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithDependencies( 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 evaluation : evaluations) { - evaluationKeys.add(evaluation.substring(0, evaluation.indexOf(':'))); + for (final String record : evaluations(evaluation)) { + evaluationKeys.add(record.substring(0, record.indexOf(':'))); } assertEquals(Arrays.asList("leaf", "a", "b"), evaluationKeys); - assertTrue(exposures.isEmpty()); + assertTrue(evaluation.dependencyExposures.isEmpty()); } } @Test void validatesInvalidMaximumDependencyDepthOnlyWhenADependencyIsReached() { - final FlagEvaluator evaluator = evaluator(new ArrayList<>(), (items, context) -> {}); + 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)); @@ -195,7 +179,7 @@ void rejectsExplicitNullMaximumDependencyDepthButDefaultsAnEmptyObject() { flags.put("dependency", stringFlag("dependency", "on", null, false)); flags.put( "dependent", stringFlag("dependent", "on", dependencyRule("dependency", "on"), false)); - final FlagEvaluator evaluator = evaluator(new ArrayList<>(), (items, context) -> {}); + final FlagEvaluator evaluator = evaluator(); assertEquals( "on-value", @@ -242,16 +226,14 @@ void rejectsNonStringDependencyMembershipOperandsWithParseError() throws Excepti .getBytes(UTF_8)); final ProviderEvaluation result = - evaluator(new ArrayList<>(), (items, context) -> {}) - .evaluate(config, String.class, "root", "default", new MutableContext("u")); + evaluator().evaluate(config, String.class, "root", "default", new MutableContext("u")); assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode()); } @Test void supportsNotOneOfAndShortCircuitsBeforeAFatalDependency() { - final List evaluations = new ArrayList<>(); - final FlagEvaluator evaluator = evaluator(evaluations, (items, context) -> {}); + final FlagEvaluator evaluator = evaluator(); final Map flags = new HashMap<>(); flags.put("dependency", stringFlag("dependency", "on", null, false)); final Rule rejectedBeforeMissing = @@ -276,53 +258,42 @@ void supportsNotOneOfAndShortCircuitsBeforeAFatalDependency() { final MutableContext context = new MutableContext("u"); context.add("gate", "closed"); - final ProviderEvaluation result = - evaluator.evaluate(config(flags, null), Integer.class, "root", 0, context); + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithDependencies(config(flags, null), Integer.class, "root", 0, context); + final ProviderEvaluation result = evaluation.details; assertEquals(1, result.getValue()); - assertEquals(singletonList("dependency:on"), evaluations); + assertEquals(singletonList("dependency:on"), evaluations(evaluation)); } @Test void emitsMissingAndInvalidPrerequisiteErrors() { - final List missing = new ArrayList<>(); final Map missingFlags = new HashMap<>(); missingFlags.put("root", stringFlag("root", "on", dependencyRule("missing", "on"), false)); - assertEquals( - ErrorCode.FLAG_NOT_FOUND, - evaluator(missing, (items, context) -> {}) - .evaluate( + final FlagEvaluator.EvaluationResult missingEvaluation = + evaluator() + .evaluateWithDependencies( config(missingFlags, null), String.class, "root", "default", - new MutableContext("u")) - .getErrorCode()); - assertEquals(singletonList("missing:FLAG_NOT_FOUND"), missing); + new MutableContext("u")); + assertEquals(ErrorCode.FLAG_NOT_FOUND, missingEvaluation.details.getErrorCode()); + assertEquals(singletonList("missing:FLAG_NOT_FOUND"), evaluations(missingEvaluation)); - final List invalid = new ArrayList<>(); final ServerConfiguration invalidConfig = config(missingFlags, null); invalidConfig.invalidFlags = singletonMap("missing", "invalid_flag"); - assertEquals( - ErrorCode.PARSE_ERROR, - evaluator(invalid, (items, context) -> {}) - .evaluate(invalidConfig, String.class, "root", "default", new MutableContext("u")) - .getErrorCode()); - assertEquals(singletonList("missing:PARSE_ERROR"), invalid); + final FlagEvaluator.EvaluationResult invalidEvaluation = + evaluator() + .evaluateWithDependencies( + 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 List evaluations = new ArrayList<>(); - final List exposures = new ArrayList<>(); - final FlagEvaluator evaluator = - evaluator( - evaluations, - (items, context) -> { - for (final FlagEvaluator.DependencyExposure item : items) { - exposures.add(item.flagKey); - } - }); + 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)); @@ -339,27 +310,37 @@ void rollsBackATransitiveTraceWhenTheRootAllocationHasNoAssignment() { integerFlag( "root", Arrays.asList(noAssignment, allocation("fallback", null, "selected", false)))); - final ProviderEvaluation result = - evaluator.evaluate(config(flags, null), Integer.class, "root", 0, new MutableContext("u")); + final FlagEvaluator.EvaluationResult evaluation = + evaluator.evaluateWithDependencies( + 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); - assertTrue(exposures.isEmpty()); + assertEquals(Arrays.asList("leaf:on", "parent:on"), evaluations(evaluation)); + assertTrue(evaluation.dependencyExposures.isEmpty()); + } + + private static FlagEvaluator evaluator() { + return new FlagEvaluator(false, (flag, subject, allocation, variant, serialId) -> false); } - private static FlagEvaluator evaluator( - final List evaluations, final FlagEvaluator.DependencyExposureSink exposureSink) { - return new FlagEvaluator( - false, - (flag, subject, allocation, variant, serialId) -> false, - (flag, details, context, timestamp) -> - evaluations.add( - flag - + ":" - + (details.getErrorCode() == null - ? details.getVariant() - : details.getErrorCode().name())), - exposureSink); + 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( From cb460967866181d4f5e946a9a79cf2d6bf0ed079 Mon Sep 17 00:00:00 2001 From: Codex GPT-5 Date: Tue, 3 Nov 2026 22:40:55 +0000 Subject: [PATCH 03/12] fix(openfeature): harden dependency exposure handoff Scope returned dependency exposures to their root hook lifecycle and isolate their delivery from ordinary root telemetry. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../com/datadog/openfeature/DDEvaluator.java | 136 +++++---- .../openfeature/ExposureLoggingHook.java | 87 +++++- .../internal/evaluation/FlagEvaluator.java | 4 +- .../openfeature/ExposureLoggingHookTest.java | 263 ++++++++++++++++++ .../DependentFlagEvaluatorTest.java | 18 +- 5 files changed, 435 insertions(+), 73 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index 3d851d58f0e..47cfefa883f 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -38,14 +38,11 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; class DDEvaluator implements Evaluator, Consumer { - private static final String DEPENDENCY_EXPOSURES_TOKEN = "__dd_dependency_exposures_token"; private static final String EVALUATION_TELEMETRY_OPT_OUT = "dd_evaluation_telemetry_opt_out"; /** @@ -97,9 +94,12 @@ class DDEvaluator implements Evaluator, Consumer { private final Settings settings; private final FlagEvaluator core; private final ConfigurationStore configuration = new ConfigurationStore(); - private final AtomicLong nextDependencyExposureToken = new AtomicLong(); - private final Map> pendingDependencyExposures = - new ConcurrentHashMap<>(); + // Dependency exposures belong to one OpenFeature hook lifecycle. The hook opens a scope before + // provider evaluation and closes it in finallyAfter, so direct provider calls neither retain + // candidates nor expose an internal hand-off token through public flag metadata. A stack keeps + // nested evaluations on the same thread isolated. + private final ThreadLocal> dependencyExposureScopes = + new ThreadLocal<>(); private volatile FeatureFlagsRuntime runtime; private volatile boolean dependencyTelemetryOptOutEnabled; private volatile FlagEvalMetrics dependencyMetrics; @@ -141,6 +141,7 @@ public boolean hasConfiguration() { @Override public synchronized void shutdown() { + dependencyExposureScopes.remove(); final FeatureFlagsRuntime released = this.runtime; this.runtime = null; if (released != null) { @@ -174,7 +175,7 @@ public ProviderEvaluation evaluate( final T defaultValue, final EvaluationContext context) { final FlagEvaluator.EvaluationResult evaluation = - core.evaluateWithDependencies(configuration.get(), target, key, defaultValue, context); + core.evaluateWithTrace(configuration.get(), target, key, defaultValue, context); for (final FlagEvaluator.DependencyEvaluation dependency : evaluation.dependencyEvaluations) { try { publishDependencyEvaluation(dependency, context, evaluation.evaluationTimestampMs); @@ -183,14 +184,12 @@ public ProviderEvaluation evaluate( } } final ProviderEvaluation result = evaluation.details; - final List staged = evaluation.dependencyExposures; - if (staged == null || staged.isEmpty() || result.getErrorCode() != null) { - return result; - } - final long token = nextDependencyExposureToken.incrementAndGet(); - pendingDependencyExposures.put(token, staged); - result.setFlagMetadata( - withLongMetadata(result.getFlagMetadata(), DEPENDENCY_EXPOSURES_TOKEN, token)); + stageDependencyExposures( + key, + context, + result.getErrorCode() == null + ? evaluation.dependencyExposures + : Collections.emptyList()); return result; } @@ -339,19 +338,65 @@ void publishDependencyEvaluation( features)); } - List takeDependencyExposures(final ImmutableMetadata metadata) { - if (metadata == null) { - return Collections.emptyList(); + void beginDependencyExposureScope( + final String rootFlagKey, final EvaluationContext evaluationContext) { + Deque scopes = dependencyExposureScopes.get(); + if (scopes == null) { + scopes = new LinkedList<>(); + dependencyExposureScopes.set(scopes); } - final Object token = metadata.asUnmodifiableMap().get(DEPENDENCY_EXPOSURES_TOKEN); - if (!(token instanceof Long)) { + scopes.push(new DependencyExposureScope(rootFlagKey, evaluationContext)); + } + + List endDependencyExposureScope() { + final Deque scopes = dependencyExposureScopes.get(); + if (scopes == null || scopes.isEmpty()) { return Collections.emptyList(); } - final List exposures = - pendingDependencyExposures.remove((Long) token); - return exposures == null - ? Collections.emptyList() - : exposures; + final List exposures = scopes.pop().exposures; + if (scopes.isEmpty()) { + dependencyExposureScopes.remove(); + } + return exposures; + } + + private void stageDependencyExposures( + final String rootFlagKey, + final EvaluationContext evaluationContext, + final List candidates) { + final Deque scopes = dependencyExposureScopes.get(); + if (scopes == null || scopes.isEmpty()) { + return; + } + final DependencyExposureScope 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; + 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( @@ -396,34 +441,23 @@ private static boolean isEvaluationTelemetryOptedOut(final EvaluationContext con return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean()); } - private static ImmutableMetadata withLongMetadata( - final ImmutableMetadata metadata, final String key, final long value) { - final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); - if (metadata != null) { - for (final Map.Entry entry : metadata.asUnmodifiableMap().entrySet()) { - addMetadata(builder, entry.getKey(), entry.getValue()); - } + private static final class DependencyExposureScope { + private final String rootFlagKey; + private final EvaluationContext evaluationContext; + private List exposures = Collections.emptyList(); + private boolean sealed; + + private DependencyExposureScope( + final String rootFlagKey, final EvaluationContext evaluationContext) { + this.rootFlagKey = rootFlagKey; + this.evaluationContext = evaluationContext; } - builder.addLong(key, value); - return builder.build(); - } - private static void addMetadata( - final ImmutableMetadata.ImmutableMetadataBuilder builder, - final String key, - final Object value) { - if (value instanceof String) { - builder.addString(key, (String) value); - } else if (value instanceof Boolean) { - builder.addBoolean(key, (Boolean) value); - } else if (value instanceof Integer) { - builder.addInteger(key, (Integer) value); - } else if (value instanceof Long) { - builder.addLong(key, (Long) value); - } else if (value instanceof Float) { - builder.addFloat(key, (Float) value); - } else if (value instanceof Double) { - builder.addDouble(key, (Double) value); + private boolean matches( + final String evaluatedFlagKey, final EvaluationContext evaluatedContext) { + return rootFlagKey != null + && rootFlagKey.equals(evaluatedFlagKey) + && evaluationContext == evaluatedContext; } } 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 a9329e187a7..3f2ba6f2b34 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -13,8 +13,10 @@ 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; @@ -37,37 +39,68 @@ 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) { - log.trace("Feature Flagging exposure suppressed: evaluation failed or details unavailable"); - return; - } - final DDEvaluator evaluator = this.evaluatorSupplier.get(); + final DDEvaluator scopedEvaluator = popEvaluatorScope(ctx); final List dependencyExposures = - evaluator == null + scopedEvaluator == null ? java.util.Collections . emptyList() - : evaluator.takeDependencyExposures(details.getFlagMetadata()); + : 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) { - evaluator.recordDependencyExposures(dependencyExposures, context); + 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) { @@ -120,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/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java index cbbced47bc5..d0580d108de 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 @@ -83,11 +83,11 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { - return evaluateWithDependencies(config, target, key, defaultValue, context).details; + return evaluateWithTrace(config, target, key, defaultValue, context).details; } /** Evaluates a root flag and returns all telemetry candidates produced by its graph. */ - public EvaluationResult evaluateWithDependencies( + public EvaluationResult evaluateWithTrace( final ServerConfiguration config, final Class target, final String key, 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/evaluation/DependentFlagEvaluatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorTest.java index 042dd1725bf..dbda5caf195 100644 --- 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 @@ -47,7 +47,7 @@ void memoizesDependenciesAndReplaysTheirExposureAfterRuleRollback() { allocation("accepted", dependencyRule("dependency", "on"), "selected", false)))); final FlagEvaluator.EvaluationResult evaluation = - evaluator.evaluateWithDependencies( + evaluator.evaluateWithTrace( config(flags, null), Integer.class, "root", 0, new MutableContext("u")); final ProviderEvaluation result = evaluation.details; @@ -69,7 +69,7 @@ void rollsBackDependencyExposuresWhenTheRootEvaluationFails() { allocation("broken", dependencyRule("dependency", "on"), "missing", false)))); final FlagEvaluator.EvaluationResult evaluation = - evaluator.evaluateWithDependencies( + evaluator.evaluateWithTrace( config(flags, null), Integer.class, "root", 0, new MutableContext("u")); final ProviderEvaluation result = evaluation.details; @@ -86,7 +86,7 @@ void propagatesCyclesAndEmitsAnErrorForEveryReachedDependency() { flags.put("b", stringFlag("b", "on", dependencyRule("a", "on"), false)); final FlagEvaluator.EvaluationResult evaluation = - evaluator.evaluateWithDependencies( + evaluator.evaluateWithTrace( config(flags, null), String.class, "a", "default", new MutableContext("u")); final ProviderEvaluation result = evaluation.details; @@ -103,7 +103,7 @@ void enforcesTheConfiguredMaximumDependencyDepth() { flags.put("b", stringFlag("b", "on", null, false)); final FlagEvaluator.EvaluationResult evaluation = - evaluator.evaluateWithDependencies( + evaluator.evaluateWithTrace( config(flags, new EvaluatorParams(1D)), String.class, "root", @@ -134,7 +134,7 @@ void memoizedSubtreeDepthFailureDoesNotReemitMemoizedNodeInEitherOrder() { flags.put("root", stringFlag("root", "on", rootRule, true)); final FlagEvaluator.EvaluationResult evaluation = - evaluator.evaluateWithDependencies( + evaluator.evaluateWithTrace( config(flags, new EvaluatorParams(2D)), String.class, "root", @@ -259,7 +259,7 @@ void supportsNotOneOfAndShortCircuitsBeforeAFatalDependency() { context.add("gate", "closed"); final FlagEvaluator.EvaluationResult evaluation = - evaluator.evaluateWithDependencies(config(flags, null), Integer.class, "root", 0, context); + evaluator.evaluateWithTrace(config(flags, null), Integer.class, "root", 0, context); final ProviderEvaluation result = evaluation.details; assertEquals(1, result.getValue()); @@ -272,7 +272,7 @@ void emitsMissingAndInvalidPrerequisiteErrors() { missingFlags.put("root", stringFlag("root", "on", dependencyRule("missing", "on"), false)); final FlagEvaluator.EvaluationResult missingEvaluation = evaluator() - .evaluateWithDependencies( + .evaluateWithTrace( config(missingFlags, null), String.class, "root", @@ -285,7 +285,7 @@ void emitsMissingAndInvalidPrerequisiteErrors() { invalidConfig.invalidFlags = singletonMap("missing", "invalid_flag"); final FlagEvaluator.EvaluationResult invalidEvaluation = evaluator() - .evaluateWithDependencies( + .evaluateWithTrace( invalidConfig, String.class, "root", "default", new MutableContext("u")); assertEquals(ErrorCode.PARSE_ERROR, invalidEvaluation.details.getErrorCode()); assertEquals(singletonList("missing:PARSE_ERROR"), evaluations(invalidEvaluation)); @@ -311,7 +311,7 @@ void rollsBackATransitiveTraceWhenTheRootAllocationHasNoAssignment() { "root", Arrays.asList(noAssignment, allocation("fallback", null, "selected", false)))); final FlagEvaluator.EvaluationResult evaluation = - evaluator.evaluateWithDependencies( + evaluator.evaluateWithTrace( config(flags, null), Integer.class, "root", 0, new MutableContext("u")); final ProviderEvaluation result = evaluation.details; From 4f034b2f5092dea23941619f1d1e37f0f18ef40d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 4 Nov 2026 13:50:22 -0600 Subject: [PATCH 04/12] fix: address dependent flag review findings Reuse dependency snapshots, preserve invalid-depth telemetry, and scale exposure deduplication. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../DependentFlagEvaluatorBenchmark.java | 83 +++++++++++++++++++ .../com/datadog/openfeature/DDEvaluator.java | 29 +++++-- .../internal/evaluation/FlagEvaluator.java | 46 ++++++---- .../ufc/UniversalFlagConfigParser.java | 2 +- .../EvaluationTelemetryOptOutTest.java | 68 +++++++++++++++ .../DependentFlagEvaluatorTest.java | 56 +++++++++---- .../ufc/UniversalFlagConfigParserTest.java | 16 ++++ 7 files changed, 260 insertions(+), 40 deletions(-) create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorBenchmark.java diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorBenchmark.java new file mode 100644 index 00000000000..8577b36aa4d --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorBenchmark.java @@ -0,0 +1,83 @@ +package com.datadog.openfeature.internal.evaluation; + +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; +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +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.MutableContext; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** Measures exposure-trace accumulation across wide dependent-flag graphs. */ +@State(Scope.Thread) +@Warmup(iterations = 3, time = 1, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(1) +public class DependentFlagEvaluatorBenchmark { + @Param({"16", "64", "256"}) + public int dependencyCount; + + private FlagEvaluator evaluator; + private ServerConfiguration configuration; + private MutableContext context; + + @Setup + public void setUp() { + evaluator = new FlagEvaluator(false, (flag, subject, allocation, variant, serialId) -> false); + context = new MutableContext("benchmark-subject"); + final Map flags = new HashMap<>(); + final List conditions = new ArrayList<>(); + for (int i = 0; i < dependencyCount; i++) { + final String key = "dependency-" + i; + flags.put(key, flag(key, null)); + conditions.add(new ConditionConfiguration(ONE_OF, null, key, singletonList("on"))); + } + flags.put("root", flag("root", new Rule(conditions))); + configuration = new ServerConfiguration("", "", false, null, flags); + } + + @Benchmark + public FlagEvaluator.EvaluationResult evaluateWideGraph() { + return evaluator.evaluateWithTrace(configuration, String.class, "root", "default", context); + } + + private static Flag flag(final String key, final Rule rule) { + final Map variants = new HashMap<>(); + variants.put("on", new Variant("on", "on-value")); + final Allocation allocation = + new Allocation( + key + "-allocation", + rule == null ? null : singletonList(rule), + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 1)), + false); + return new Flag(key, true, ValueType.STRING, variants, singletonList(allocation)); + } +} 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 4f2cb4e1fc1..99053491ece 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -269,7 +269,8 @@ void publishDependencyEvaluation( final FlagEvaluator.DependencyEvaluation dependency, final EvaluationContext context, final long evaluationTimestampMs, - final FlagEvaluationWriter writer) { + final FlagEvaluationWriter writer, + final DependencyContextSnapshot contextSnapshot) { final ImmutableMetadata metadata = dependency.metadata; final Map values = metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); @@ -290,11 +291,7 @@ void publishDependencyEvaluation( } final Map attrs; if (observeFullEvaluationData) { - final CopyResult copy = copyPrunedContext(context); - if (copy.truncatedReason != null) { - writer.countContextTruncated(copy.truncatedReason); - } - attrs = copy.attrs; + attrs = contextSnapshot.capture(context, writer); } else { attrs = Collections.emptyMap(); } @@ -319,15 +316,33 @@ void publishDependencyEvaluations( final DependencyEvaluationTrace trace, final EvaluationContext context, final FlagEvaluationWriter writer) { + final DependencyContextSnapshot contextSnapshot = new DependencyContextSnapshot(); for (final FlagEvaluator.DependencyEvaluation dependency : trace.evaluations) { try { - publishDependencyEvaluation(dependency, context, trace.evaluationTimestampMs, writer); + publishDependencyEvaluation( + dependency, context, trace.evaluationTimestampMs, writer, contextSnapshot); } catch (final RuntimeException | LinkageError ignored) { // One telemetry record must not suppress the root or later dependency records. } } } + /** Lazily captures one detached evaluation-context snapshot for a dependency batch. */ + private static final class DependencyContextSnapshot { + private CopyResult copy; + + private Map capture( + final EvaluationContext context, final FlagEvaluationWriter writer) { + if (copy == null) { + copy = copyPrunedContext(context); + if (copy.truncatedReason != null) { + writer.countContextTruncated(copy.truncatedReason); + } + } + return copy.attrs; + } + } + void recordDependencyMetrics( final DependencyEvaluationTrace trace, final FlagEvalMetrics metrics) { if (metrics == null) { 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 3f810768fde..c7b1485a85d 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 @@ -757,7 +757,7 @@ private EvaluationState( private DependencyEvaluationSession dependencySession(final String rootFlagKey) { if (session == null) { - session = new DependencyEvaluationSession(rootFlagKey, maxDependencyDepth(config), this); + session = new DependencyEvaluationSession(rootFlagKey, this); } return session; } @@ -781,7 +781,6 @@ private EvaluationResult complete( } private final class DependencyEvaluationSession { - private final int maxDependencyDepth; private final EvaluationState state; private final Map memo = new HashMap<>(); private final Set active = new HashSet<>(); @@ -789,10 +788,10 @@ private final class DependencyEvaluationSession { private final Set evaluatedFlags = new HashSet<>(); private final List evaluations = new ArrayList<>(); private final List exposures = new ArrayList<>(); + private final Set exposureKeys = new HashSet<>(); + private Integer maxDependencyDepth; - private DependencyEvaluationSession( - final String rootFlagKey, final int maxDependencyDepth, final EvaluationState state) { - this.maxDependencyDepth = maxDependencyDepth; + private DependencyEvaluationSession(final String rootFlagKey, final EvaluationState state) { this.state = state; active.add(rootFlagKey); } @@ -805,12 +804,13 @@ private ExposureCheckpoint checkpoint() { private void rollback(final ExposureCheckpoint checkpoint) { while (exposures.size() > checkpoint.exposureCount) { - exposures.remove(exposures.size() - 1); + exposureKeys.remove(exposures.remove(exposures.size() - 1).flagKey); } if (!frames.isEmpty()) { final DependencyFrame frame = frames.get(frames.size() - 1); while (frame.exposureTrace.size() > checkpoint.frameExposureCount) { - frame.exposureTrace.remove(frame.exposureTrace.size() - 1); + frame.exposureKeys.remove( + frame.exposureTrace.remove(frame.exposureTrace.size() - 1).flagKey); } } } @@ -825,6 +825,13 @@ private EvaluationResult complete( } private String resolveCurrent(final String flagKey) { + if (maxDependencyDepth == null) { + try { + maxDependencyDepth = FlagEvaluator.maxDependencyDepth(state.config); + } catch (final DependencyGraphException e) { + throw emitFailure(flagKey, e.errorCode, e.getMessage()); + } + } final int depth = frames.isEmpty() ? 1 : frames.get(frames.size() - 1).depth + 1; return resolve(flagKey, depth); } @@ -887,7 +894,8 @@ private String resolve(final String flagKey, final int depth) { ErrorCode.GENERAL, "prerequisite flag evaluation returned no variant"); } final List exposureTrace = new ArrayList<>(frame.exposureTrace); - appendUnique(exposureTrace, new DependencyExposure(flagKey, details)); + final Set exposureTraceKeys = new HashSet<>(frame.exposureKeys); + appendUnique(exposureTrace, exposureTraceKeys, new DependencyExposure(flagKey, details)); final MemoizedDependency result = new MemoizedDependency(details, frame.maxReachedDepth - depth, exposureTrace); memo.put(flagKey, result); @@ -944,18 +952,20 @@ private void recordReachedDepthOnParent(final int depth) { private void recordExposureTrace(final List trace) { for (final DependencyExposure candidate : trace) { - appendUnique(exposures, candidate); + appendUnique(exposures, exposureKeys, candidate); if (!frames.isEmpty()) { - appendUnique(frames.get(frames.size() - 1).exposureTrace, candidate); + final DependencyFrame frame = frames.get(frames.size() - 1); + appendUnique(frame.exposureTrace, frame.exposureKeys, candidate); } } } private void recordExposureTraceOnParent(final List trace) { for (final DependencyExposure candidate : trace) { - appendUnique(exposures, candidate); + appendUnique(exposures, exposureKeys, candidate); if (frames.size() > 1) { - appendUnique(frames.get(frames.size() - 2).exposureTrace, candidate); + final DependencyFrame parent = frames.get(frames.size() - 2); + appendUnique(parent.exposureTrace, parent.exposureKeys, candidate); } } } @@ -985,19 +995,19 @@ private ProviderEvaluation evaluateDeclaredFlag( } private static void appendUnique( - final List target, final DependencyExposure candidate) { - for (final DependencyExposure existing : target) { - if (existing.flagKey.equals(candidate.flagKey)) { - return; - } + final List target, + final Set keys, + final DependencyExposure candidate) { + if (keys.add(candidate.flagKey)) { + target.add(candidate); } - target.add(candidate); } private static final class DependencyFrame { private final int depth; private int maxReachedDepth; private final List exposureTrace = new ArrayList<>(); + private final Set exposureKeys = new HashSet<>(); private DependencyFrame(final int depth) { this.depth = depth; 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 fde311825b2..b2222633d98 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 @@ -301,7 +301,7 @@ private static String toFlagEvaluationKey(@Nullable final Object value) { throw new IllegalArgumentException("flagEvaluation must contain only key"); } final String key = asString(json.get("key"), "flagEvaluation.key"); - if (key.isEmpty()) { + if (key == null || key.isEmpty()) { throw new IllegalArgumentException("flagEvaluation.key must not be empty"); } return key; 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 3ea5ae87bd4..d3e62ca1ee4 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -56,6 +57,7 @@ import io.opentelemetry.api.metrics.LongCounter; import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -544,6 +546,29 @@ void dependencyTelemetryIncludesMetricsWhenProviderOptOutIsDisabled() throws Exc assertEquals(2, this.exposures.size()); } + @Test + void dependencyTelemetryReusesOneDetachedContextSnapshotPerBatch() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + twoDependencyConfiguration(true)); + final MutableContext context = new MutableContext("subject"); + context.add("region", "us-east-1"); + + final FlagEvaluationDetails included = + this.client.getIntegerDetails("root", 0, context); + context.add("region", "eu-west-1"); + + assertEquals(1, included.getValue()); + assertEquals(3, this.events.size(), "two dependencies and the root evaluation"); + assertEquals("first", this.events.get(0).flagKey); + assertEquals("second", this.events.get(1).flagKey); + assertSame( + this.events.get(0).attrs, + this.events.get(1).attrs, + "dependency events in one batch must reuse the detached snapshot"); + assertEquals("us-east-1", this.events.get(0).attrs.get("region")); + } + @TableTest({ "initial | after", "false | true ", @@ -956,6 +981,49 @@ private static ServerConfiguration dependentConfiguration() { return new ServerConfiguration("", "", false, null, flags); } + private static ServerConfiguration twoDependencyConfiguration( + final boolean observeFullEvaluationData) { + final Map flags = new HashMap<>(); + final List conditions = new ArrayList<>(); + for (final String key : asList("first", "second")) { + final Allocation dependencyAllocation = + new Allocation( + key + "-allocation", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 7)), + false); + flags.put( + key, + new Flag( + key, + true, + ValueType.INTEGER, + singletonMap("on", new Variant("on", 1)), + singletonList(dependencyAllocation))); + conditions.add( + new ConditionConfiguration(ConditionOperator.ONE_OF, null, key, singletonList("on"))); + } + final Allocation rootAllocation = + new Allocation( + "root-allocation", + singletonList(new Rule(conditions)), + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 7)), + false); + flags.put( + "root", + new Flag( + "root", + true, + ValueType.INTEGER, + singletonMap("on", new Variant("on", 1)), + singletonList(rootAllocation))); + return new ServerConfiguration("", "", observeFullEvaluationData, null, flags); + } + private static ServerConfiguration targetingConfiguration( final boolean observeFullEvaluationData) { final Rule rule = 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 index 64dc44ea0cb..98bdb92118b 100644 --- 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 @@ -243,12 +243,15 @@ void validatesInvalidMaximumDependencyDepthOnlyWhenADependencyIsReached() { 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")); + final FlagEvaluator.EvaluationResult dependent = + evaluator.evaluateWithTrace( + config, String.class, "dependent", "default", new MutableContext("u")); assertEquals("on-value", ordinary.getValue()); - assertEquals(ErrorCode.PARSE_ERROR, dependent.getErrorCode()); - assertNotNull(dependent.getFlagMetadata()); + assertEquals(ErrorCode.PARSE_ERROR, dependent.details.getErrorCode()); + assertNotNull(dependent.details.getFlagMetadata()); + assertEquals(singletonList("dependency:PARSE_ERROR"), evaluations(dependent)); + assertTrue(dependent.dependencyExposures.isEmpty()); } @Test @@ -270,16 +273,16 @@ void rejectsExplicitNullMaximumDependencyDepthButDefaultsAnEmptyObject() { "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()); + final FlagEvaluator.EvaluationResult explicitNull = + evaluator.evaluateWithTrace( + config(flags, new EvaluatorParams(null, true)), + String.class, + "dependent", + "default", + new MutableContext("u")); + assertEquals(ErrorCode.PARSE_ERROR, explicitNull.details.getErrorCode()); + assertEquals(singletonList("dependency:PARSE_ERROR"), evaluations(explicitNull)); + assertTrue(explicitNull.dependencyExposures.isEmpty()); assertEquals( "on-value", evaluator @@ -292,6 +295,31 @@ void rejectsExplicitNullMaximumDependencyDepthButDefaultsAnEmptyObject() { .getValue()); } + @Test + void preservesWideDependencyExposureOrderWithoutDuplicates() { + final int dependencyCount = 512; + final Map flags = new HashMap<>(); + final List conditions = new ArrayList<>(); + for (int i = 0; i < dependencyCount; i++) { + final String key = "dependency-" + i; + flags.put(key, stringFlag(key, "on", null, false)); + conditions.add(new ConditionConfiguration(ONE_OF, null, key, singletonList("on"))); + } + flags.put("root", stringFlag("root", "on", new Rule(conditions), false)); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator() + .evaluateWithTrace( + config(flags, null), String.class, "root", "default", new MutableContext("u")); + + assertEquals("on-value", evaluation.details.getValue()); + assertEquals(dependencyCount, evaluation.dependencyEvaluations.size()); + assertEquals(dependencyCount, evaluation.dependencyExposures.size()); + for (int i = 0; i < dependencyCount; i++) { + assertEquals("dependency-" + i, evaluation.dependencyExposures.get(i).flagKey); + } + } + @Test void rejectsNonStringDependencyMembershipOperandsWithParseError() throws Exception { final ServerConfiguration config = 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 e8113e7936a..34f785504ac 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 @@ -112,6 +112,22 @@ void rejectsConditionsWithBothAttributeAndFlagEvaluation() throws Exception { assertEquals("invalid_flag", config.invalidFlags.get("bad")); } + @Test + void rejectsNullDependentFlagKeyAndKeepsSiblingFlag() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{" + + "\"bad\":{\"key\":\"bad\",\"allocations\":[{\"key\":\"a\"," + + "\"rules\":[{\"conditions\":[{\"operator\":\"ONE_OF\"," + + "\"flagEvaluation\":{\"key\":null},\"value\":[\"on\"]}]}]," + + "\"splits\":[]}]}," + + "\"good\":{\"key\":\"good\",\"enabled\":true}}}"); + + assertFalse(config.flags.containsKey("bad")); + assertEquals("invalid_flag", config.invalidFlags.get("bad")); + assertTrue(config.flags.get("good").enabled); + } + @Test void parsesNullDocumentAsNoConfiguration() throws Exception { assertNull(deserialize("null")); From 875d7f137d7aa3e90361fb68d9400daab40e4558 Mon Sep 17 00:00:00 2001 From: "tyler.potter" Date: Wed, 4 Nov 2026 21:05:07 +0000 Subject: [PATCH 05/12] fix: address dependent flag lifecycle review findings Bind returned telemetry to its evaluation lifecycle and avoid ordinary-path and repeated snapshot allocations. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../DependentFlagClientBenchmark.java | 183 ++++++++++++++++ .../com/datadog/openfeature/DDEvaluator.java | 176 ++++++++++++---- .../openfeature/ExposureLoggingHook.java | 53 +---- .../openfeature/FlagEvalLoggingHook.java | 4 +- .../openfeature/FlagEvalMetricsHook.java | 3 +- .../internal/evaluation/FlagEvaluator.java | 1 + .../openfeature/ExposureLoggingHookTest.java | 195 +++++++++++++++++- .../DependentFlagEvaluatorTest.java | 19 ++ 8 files changed, 544 insertions(+), 90 deletions(-) create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/DependentFlagClientBenchmark.java diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/DependentFlagClientBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/DependentFlagClientBenchmark.java new file mode 100644 index 00000000000..6b7219cc32f --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/DependentFlagClientBenchmark.java @@ -0,0 +1,183 @@ +package com.datadog.openfeature; + +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; +import static java.util.concurrent.TimeUnit.MICROSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.datadog.openfeature.internal.bridge.v1.AgentBridge; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; +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.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** Profiles the real OpenFeature client/hook path for ordinary and dependent roots. */ +@State(Scope.Thread) +@Warmup(iterations = 3, time = 1, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(MICROSECONDS) +@Fork(1) +public class DependentFlagClientBenchmark { + @Param({"0", "32"}) + public int contextAttributes; + + private final BenchmarkEvaluator evaluator = new BenchmarkEvaluator(); + private final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + private Client client; + private MutableContext context; + + @Setup(Level.Trial) + public void setUp() throws Exception { + api.clearHooks(); + api.shutdown(); + evaluator.accept(configuration(16)); + api.setProviderAndWait( + new Provider( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + evaluator, + false)); + client = api.getClient(); + context = new MutableContext("benchmark-subject"); + for (int i = 0; i < contextAttributes; i++) { + context.add("attribute-" + i, "value-" + i); + } + if (ordinaryClientEvaluation().getErrorCode() != null + || dependentExposureHook().getErrorCode() != null) { + throw new IllegalStateException("benchmark configuration did not evaluate successfully"); + } + } + + @Setup(Level.Invocation) + public void clearExposureCache() { + evaluator.cache.clear(); + } + + @Benchmark + public FlagEvaluationDetails ordinaryClientEvaluation() { + return client.getIntegerDetails("ordinary", 0, context); + } + + /** Includes returned-batch publication and all dependency exposure hooks after cache misses. */ + @Benchmark + public FlagEvaluationDetails dependentExposureHook() { + return client.getIntegerDetails("root", 0, context); + } + + @TearDown(Level.Trial) + public void tearDown() { + api.clearHooks(); + api.shutdown(); + } + + private static ServerConfiguration configuration(final int dependencyCount) { + final Map flags = new HashMap<>(); + flags.put("ordinary", integerFlag("ordinary", null, false)); + final List conditions = new ArrayList<>(); + for (int i = 0; i < dependencyCount; i++) { + final String key = "dependency-" + i; + flags.put(key, stringFlag(key)); + conditions.add(new ConditionConfiguration(ONE_OF, null, key, singletonList("on"))); + } + flags.put("root", integerFlag("root", new Rule(conditions), false)); + return new ServerConfiguration("", "", false, null, flags); + } + + private static Flag integerFlag(final String key, final Rule rule, final boolean doLog) { + final Map variants = new HashMap<>(); + variants.put("selected", new Variant("selected", 1)); + return new Flag( + key, + true, + ValueType.INTEGER, + variants, + singletonList( + new Allocation( + key + "-allocation", + rule == null ? null : singletonList(rule), + null, + null, + singletonList(new Split(emptyList(), "selected", emptyMap(), 1)), + doLog))); + } + + private static Flag stringFlag(final String key) { + final Map variants = new HashMap<>(); + variants.put("on", new Variant("on", "on-value")); + return new Flag( + key, + true, + ValueType.STRING, + variants, + singletonList( + new Allocation( + key + "-allocation", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 1)), + true))); + } + + private static final class BenchmarkEvaluator extends DDEvaluator { + private final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(); + private volatile int exposureCount; + + private BenchmarkEvaluator() { + super(() -> {}, AgentBridge.NONE, Settings.load(AgentBridge.NONE)); + } + + @Override + public boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext evaluationContext) { + return hasConfiguration(); + } + + @Override + ExposureDeduplicationCache exposureCache() { + return cache; + } + + @Override + boolean isExposureDeliveryEnabled() { + return true; + } + + @Override + void recordExposure(final ExposureEvent event) { + exposureCount++; + } + } +} 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 99053491ece..4717514a202 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -19,9 +19,13 @@ 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.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.ValueType; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.ProviderEvaluation; @@ -97,7 +101,7 @@ class DDEvaluator implements Evaluator, Consumer { // 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 = + private final ThreadLocal> dependencyTelemetryScopes = new ThreadLocal<>(); private volatile FeatureFlagsRuntime runtime; @@ -176,10 +180,11 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { + final DependencyTelemetryToken token = claimDependencyTelemetry(key, context); final FlagEvaluator.EvaluationResult evaluation = core.evaluateWithTrace(configuration.get(), target, key, defaultValue, context); final ProviderEvaluation result = evaluation.details; - stageDependencyTelemetry(key, context, evaluation); + stageDependencyTelemetry(token, evaluation); return result; } @@ -253,6 +258,24 @@ static ExposureEvent exposureEvent( features.isEmpty() ? null : features); } + private static ExposureEvent exposureEvent( + final String flag, + final String allocationKey, + final String variantKey, + final String targetingKey, + final Map contextAttributes, + final Integer serialId, + final Map features) { + return new ExposureEvent( + System.currentTimeMillis(), + new com.datadog.openfeature.internal.exposure.Allocation(allocationKey), + new com.datadog.openfeature.internal.exposure.Flag(flag), + new com.datadog.openfeature.internal.exposure.Variant(variantKey), + new Subject(targetingKey, contextAttributes), + serialId, + features.isEmpty() ? null : features); + } + /** * Records an exposure to the runtime, if initialized. * @@ -368,19 +391,53 @@ void recordDependencyMetrics( } } - void beginDependencyExposureScope( + DependencyTelemetryToken beginDependencyExposureScope( final String rootFlagKey, final EvaluationContext evaluationContext) { - Deque scopes = dependencyTelemetryScopes.get(); + if (!hasDependencyConditions(rootFlagKey)) { + return null; + } + Deque scopes = dependencyTelemetryScopes.get(); if (scopes == null) { scopes = new LinkedList<>(); dependencyTelemetryScopes.set(scopes); } - scopes.push(new DependencyTelemetryScope(rootFlagKey, evaluationContext)); + final DependencyTelemetryToken token = + new DependencyTelemetryToken(this, rootFlagKey, evaluationContext); + scopes.push(token); + return token; } - List endDependencyExposureScope() { - final Deque scopes = dependencyTelemetryScopes.get(); - if (scopes == null || scopes.isEmpty()) { + private boolean hasDependencyConditions(final String rootFlagKey) { + final ServerConfiguration config = configuration.get(); + final Flag flag = + config == null || config.flags == null || rootFlagKey == null + ? null + : config.flags.get(rootFlagKey); + if (flag == null || flag.allocations == null) { + return false; + } + for (final Allocation allocation : flag.allocations) { + if (allocation == null || allocation.rules == null) { + continue; + } + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition != null && condition.flagEvaluationKey != null) { + return true; + } + } + } + } + return false; + } + + List endDependencyExposureScope( + final DependencyTelemetryToken token) { + final Deque scopes = dependencyTelemetryScopes.get(); + if (token == null || scopes == null || scopes.isEmpty() || scopes.peek() != token) { return Collections.emptyList(); } final List exposures = scopes.pop().exposures; @@ -390,37 +447,74 @@ List endDependencyExposureScope() { return exposures; } - DependencyEvaluationTrace dependencyEvaluationTrace( - final String rootFlagKey, final EvaluationContext evaluationContext) { - final Deque scopes = dependencyTelemetryScopes.get(); + List endDependencyExposureScope() { + final Deque scopes = dependencyTelemetryScopes.get(); + return scopes == null || scopes.isEmpty() + ? Collections.emptyList() + : endDependencyExposureScope(scopes.peek()); + } + + void endUnclaimedDependencyExposureScope() { + final Deque scopes = dependencyTelemetryScopes.get(); + if (scopes == null || scopes.isEmpty() || scopes.peek().claimed) { + return; + } + endDependencyExposureScope(scopes.peek()); + } + + DependencyTelemetryToken dependencyTelemetryToken(final FlagEvaluationDetails details) { + if (details == null) { + return null; + } + final Deque scopes = dependencyTelemetryScopes.get(); if (scopes == null || scopes.isEmpty()) { + return null; + } + final DependencyTelemetryToken token = scopes.peek(); + return token.sealed + && token.rootMetadata != null + && token.rootMetadata == details.getFlagMetadata() + ? token + : null; + } + + DependencyEvaluationTrace dependencyEvaluationTrace(final DependencyTelemetryToken token) { + final Deque scopes = dependencyTelemetryScopes.get(); + if (token == null || scopes == null || scopes.isEmpty() || scopes.peek() != token) { return DependencyEvaluationTrace.EMPTY; } - final DependencyTelemetryScope scope = scopes.peek(); - return scope.sealed && scope.matches(rootFlagKey, evaluationContext) - ? scope.evaluationTrace - : DependencyEvaluationTrace.EMPTY; + return token.sealed ? token.evaluationTrace : DependencyEvaluationTrace.EMPTY; } - private void stageDependencyTelemetry( - final String rootFlagKey, - final EvaluationContext evaluationContext, - final FlagEvaluator.EvaluationResult evaluation) { - final Deque scopes = dependencyTelemetryScopes.get(); + private DependencyTelemetryToken claimDependencyTelemetry( + final String rootFlagKey, final EvaluationContext evaluationContext) { + final Deque scopes = dependencyTelemetryScopes.get(); if (scopes == null || scopes.isEmpty()) { - return; + return null; } - final DependencyTelemetryScope scope = scopes.peek(); - if (scope.sealed || !scope.matches(rootFlagKey, evaluationContext)) { + final DependencyTelemetryToken token = scopes.peek(); + if (token.claimed || !token.matches(rootFlagKey, evaluationContext)) { + return null; + } + token.claimed = true; + return token; + } + + private void stageDependencyTelemetry( + final DependencyTelemetryToken token, final FlagEvaluator.EvaluationResult evaluation) { + if (token == null || token.sealed) { 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); + token.sealed = true; + token.rootMetadata = evaluation.details.getFlagMetadata(); + if (!evaluation.dependencyEvaluations.isEmpty()) { + token.evaluationTrace = + new DependencyEvaluationTrace( + evaluation.dependencyEvaluations, evaluation.evaluationTimestampMs); + } final List candidates = evaluation.details.getErrorCode() == null ? evaluation.dependencyExposures @@ -444,7 +538,7 @@ private void stageDependencyTelemetry( eligible.add(candidate); } if (eligible != null) { - scope.exposures = Collections.unmodifiableList(eligible); + token.exposures = Collections.unmodifiableList(eligible); } } @@ -454,6 +548,9 @@ void recordDependencyExposures( if (cache == null || context == null) { return; } + final String targetingKey = context.getTargetingKey(); + final boolean exposureDeliveryEnabled = isExposureDeliveryEnabled(); + Map contextAttributes = null; for (final FlagEvaluator.DependencyExposure exposure : exposures) { final ImmutableMetadata metadata = exposure.metadata; final Map values = @@ -470,15 +567,18 @@ void recordDependencyExposures( } 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()) { + if (cache.recordIfChanged(exposure.flagKey, targetingKey, allocationKey, variantKey, serialId) + && exposureDeliveryEnabled) { + if (contextAttributes == null) { + contextAttributes = Collections.unmodifiableMap(flattenContext(context)); + } recordExposure( exposureEvent( exposure.flagKey, allocationKey, variantKey, - context, + targetingKey, + contextAttributes, serialId, SplitFeatureMetadata.featuresWithPrefix(metadata, EXPOSURE_FEATURE_PREFIX))); } @@ -500,15 +600,21 @@ private DependencyEvaluationTrace( } } - private static final class DependencyTelemetryScope { + static final class DependencyTelemetryToken { + final DDEvaluator evaluator; private final String rootFlagKey; private final EvaluationContext evaluationContext; private DependencyEvaluationTrace evaluationTrace = DependencyEvaluationTrace.EMPTY; private List exposures = Collections.emptyList(); + private ImmutableMetadata rootMetadata; + private boolean claimed; private boolean sealed; - private DependencyTelemetryScope( - final String rootFlagKey, final EvaluationContext evaluationContext) { + private DependencyTelemetryToken( + final DDEvaluator evaluator, + final String rootFlagKey, + final EvaluationContext evaluationContext) { + this.evaluator = evaluator; this.rootFlagKey = rootFlagKey; this.evaluationContext = evaluationContext; } 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 3f2ba6f2b34..df67f64a395 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -13,7 +13,6 @@ 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; @@ -39,7 +38,6 @@ 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; @@ -48,23 +46,15 @@ class ExposureLoggingHook implements Hook { @Override public Optional before( final HookContext ctx, final Map hints) { - DDEvaluator evaluator = null; try { - evaluator = this.evaluatorSupplier.get(); + final DDEvaluator 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(); } @@ -74,21 +64,24 @@ public void finallyAfter( final FlagEvaluationDetails details, final Map hints) { try { - final DDEvaluator scopedEvaluator = popEvaluatorScope(ctx); + final DDEvaluator evaluator = this.evaluatorSupplier.get(); + final DDEvaluator.DependencyTelemetryToken token = + evaluator == null ? null : evaluator.dependencyTelemetryToken(details); final List dependencyExposures = - scopedEvaluator == null + token == null ? java.util.Collections . emptyList() - : scopedEvaluator.endDependencyExposureScope(); + : evaluator.endDependencyExposureScope(token); + if (evaluator != null && token == null) { + evaluator.endUnclaimedDependencyExposureScope(); + } 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; @@ -157,37 +150,9 @@ public void finallyAfter( } } - 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 3ba4a674826..3d5861ad898 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -79,7 +79,9 @@ public void finallyAfter( if (evaluator != null && ctx != null) { try { evaluator.publishDependencyEvaluations( - evaluator.dependencyEvaluationTrace(ctx.getFlagKey(), ctx.getCtx()), ctx.getCtx(), w); + evaluator.dependencyEvaluationTrace(evaluator.dependencyTelemetryToken(details)), + ctx.getCtx(), + w); } catch (final RuntimeException | LinkageError ignored) { // Dependency telemetry is best effort and must not suppress the root event. } 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 c8fb377fb27..69e1aa9b752 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java @@ -33,7 +33,8 @@ public void finallyAfter( final DDEvaluator evaluator = evaluatorSupplier.get(); if (evaluator != null && ctx != null) { evaluator.recordDependencyMetrics( - evaluator.dependencyEvaluationTrace(ctx.getFlagKey(), ctx.getCtx()), metrics); + evaluator.dependencyEvaluationTrace(evaluator.dependencyTelemetryToken(details)), + metrics); } } catch (final RuntimeException | LinkageError ignored) { // Dependency metrics are best effort and must not suppress the root metric. 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 c7b1485a85d..394189dc1f8 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 @@ -794,6 +794,7 @@ private final class DependencyEvaluationSession { private DependencyEvaluationSession(final String rootFlagKey, final EvaluationState state) { this.state = state; active.add(rootFlagKey); + evaluatedFlags.add(rootFlagKey); } private ExposureCheckpoint checkpoint() { 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 2f381b5885b..e09b26adf99 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java @@ -11,7 +11,9 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import com.datadog.openfeature.Provider.Options; @@ -19,6 +21,8 @@ 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.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ConditionConfiguration; import com.datadog.openfeature.internal.ufc.Flag; @@ -180,6 +184,132 @@ void doesNotStageIneligibleDependencyExposures() throws Exception { assertTrue(dependent.sentToDatadog.isEmpty()); } + @Test + void ordinaryFlagLifecycleDoesNotCreateADependencyScope() { + final HookContext context = hookContext(); + + assertNull(this.evaluator.beginDependencyExposureScope(context.getFlagKey(), context.getCtx())); + assertTrue(this.evaluator.endDependencyExposureScope().isEmpty()); + } + + @Test + void finallyAfterCleansUpAStartedScopeWhenProviderEvaluationWasSkipped() { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(dependentConfiguration(true), true); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> dependent); + final HookContext context = + HookContext.builder() + .flagKey("root") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(new MutableContext("subject")) + .build(); + + hook.before(context, emptyMap()); + hook.finallyAfter( + context, + FlagEvaluationDetails.builder() + .flagKey("root") + .value(0) + .reason("ERROR") + .errorCode(ErrorCode.GENERAL) + .build(), + emptyMap()); + + assertTrue(dependent.endDependencyExposureScope().isEmpty()); + } + + @Test + void skippedNestedBeforeCannotReadTheOuterBatchForTheSameFlagAndContext() { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(dependentConfiguration(true), true); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> dependent); + final MutableContext context = new MutableContext("subject"); + final HookContext outer = + HookContext.builder() + .flagKey("root") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(context) + .build(); + final HookContext skippedNested = + HookContext.builder() + .flagKey("root") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(context) + .build(); + final Map outerHints = new HashMap<>(); + final Map skippedNestedHints = new HashMap<>(); + + hook.before(outer, outerHints); + final ProviderEvaluation evaluation = + dependent.evaluate(Integer.class, "root", 0, context); + final FlagEvaluationDetails outerDetails = + FlagEvaluationDetails.from(evaluation, "root"); + final FlagEvaluationDetails skippedNestedDetails = + FlagEvaluationDetails.builder() + .flagKey("root") + .value(0) + .reason("ERROR") + .errorCode(ErrorCode.GENERAL) + .build(); + + assertNull(dependent.dependencyTelemetryToken(skippedNestedDetails)); + assertNotNull(dependent.dependencyTelemetryToken(outerDetails)); + + final List events = new java.util.ArrayList<>(); + final FlagEvaluationWriter writer = + new FlagEvaluationWriter() { + @Override + public boolean hasCapacityForEnqueue() { + return true; + } + + @Override + public void countPreQueueOverflow() {} + + @Override + public void countContextTruncated(final String reason) {} + + @Override + public void enqueue(final FlagEvalEvent event) { + events.add(event); + } + }; + final FlagEvalLoggingHook evaluationHook = + new FlagEvalLoggingHook<>(() -> writer, () -> dependent); + evaluationHook.finallyAfter(skippedNested, skippedNestedDetails, skippedNestedHints); + evaluationHook.finallyAfter(outer, outerDetails, outerHints); + + assertEquals(3, events.size(), "inner root, then outer dependency and root"); + assertEquals("root", events.get(0).flagKey); + assertEquals("dependency", events.get(1).flagKey); + assertEquals("root", events.get(2).flagKey); + + hook.finallyAfter(outer, outerDetails, outerHints); + assertEquals(1, dependent.sentToDatadog.size()); + } + + @Test + void dependencyExposuresReuseOneDetachedContextSnapshot() throws Exception { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(twoLoggedDependenciesConfiguration(), true); + OpenFeatureAPI.getInstance() + .setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), dependent)); + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + + OpenFeatureAPI.getInstance().getClient().getIntegerDetails("root", 0, context); + context.add("region", "eu-west-1"); + + assertEquals(2, dependent.sentToDatadog.size()); + assertSame( + dependent.sentToDatadog.get(0).subject.attributes, + dependent.sentToDatadog.get(1).subject.attributes); + assertEquals("us-east-1", dependent.sentToDatadog.get(0).subject.attributes.get("region")); + } + @Test void directProviderEvaluationDoesNotRetainOrExposeDependencyState() throws Exception { final ExposureTestEvaluator dependent = @@ -226,13 +356,14 @@ void reentrantDirectEvaluationCannotReplaceTheOuterRootsDependencyExposures() { .defaultValue(0) .ctx(outerContext) .build(); + final Map hints = new HashMap<>(); - hook.before(outerHookContext, emptyMap()); + hook.before(outerHookContext, hints); 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()); + outerHookContext, FlagEvaluationDetails.from(outerEvaluation, "root-a"), hints); assertEquals(1, dependent.sentToDatadog.size()); assertEquals("dependency-a", dependent.sentToDatadog.get(0).flag.key); @@ -259,8 +390,9 @@ void unmatchedNestedFinallyCannotPopTheOuterDependencyExposureScope() { .defaultValue(0) .ctx(new MutableContext("subject-b")) .build(); + final Map outerHints = new HashMap<>(); - hook.before(outerHookContext, emptyMap()); + hook.before(outerHookContext, outerHints); final ProviderEvaluation outerEvaluation = dependent.evaluate(Integer.class, "root-a", 0, outerContext); @@ -275,19 +407,20 @@ void unmatchedNestedFinallyCannotPopTheOuterDependencyExposureScope() { .build(), emptyMap()); hook.finallyAfter( - outerHookContext, FlagEvaluationDetails.from(outerEvaluation, "root-a"), emptyMap()); + outerHookContext, FlagEvaluationDetails.from(outerEvaluation, "root-a"), outerHints); 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()); + // A subsequent lifecycle proves the evaluator's token stack was removed as well. + final Map innerHints = new HashMap<>(); + hook.before(innerHookContext, innerHints); final ProviderEvaluation innerEvaluation = dependent.evaluate(Integer.class, "root-b", 0, innerHookContext.getCtx()); hook.finallyAfter( - innerHookContext, FlagEvaluationDetails.from(innerEvaluation, "root-b"), emptyMap()); + innerHookContext, FlagEvaluationDetails.from(innerEvaluation, "root-b"), innerHints); assertEquals(2, dependent.sentToDatadog.size()); assertEquals("dependency-b", dependent.sentToDatadog.get(1).flag.key); } @@ -305,13 +438,14 @@ void emptyMatchingEvaluationSealsTheOuterDependencyExposureScope() { .defaultValue(0) .ctx(context) .build(); + final Map hints = new HashMap<>(); - hook.before(hookContext, emptyMap()); + hook.before(hookContext, hints); 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()); + hook.finallyAfter(hookContext, FlagEvaluationDetails.from(outerEvaluation, "root"), hints); assertTrue(dependent.sentToDatadog.isEmpty()); } @@ -526,6 +660,49 @@ private static ServerConfiguration reentrantDependentConfiguration() { return new ServerConfiguration("", "", false, null, flags); } + private static ServerConfiguration twoLoggedDependenciesConfiguration() { + final Map flags = new HashMap<>(); + final List conditions = new java.util.ArrayList<>(); + for (final String dependencyKey : java.util.Arrays.asList("first", "second")) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", "on-value")); + flags.put( + dependencyKey, + new Flag( + dependencyKey, + true, + ValueType.STRING, + variations, + singletonList( + new Allocation( + dependencyKey + "-allocation", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 8)), + true)))); + conditions.add(new ConditionConfiguration(ONE_OF, null, dependencyKey, singletonList("on"))); + } + final Map rootVariations = new HashMap<>(); + rootVariations.put("selected", new Variant("selected", 1)); + flags.put( + "root", + new Flag( + "root", + true, + ValueType.INTEGER, + rootVariations, + singletonList( + new Allocation( + "root-allocation", + singletonList(new Rule(conditions)), + null, + null, + singletonList(new Split(emptyList(), "selected", emptyMap(), 9)), + false)))); + return new ServerConfiguration("", "", false, null, flags); + } + private static void addDependentFlagPair( final Map flags, final String rootKey, 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 index 98bdb92118b..eca9bbe51cf 100644 --- 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 @@ -295,6 +295,25 @@ void rejectsExplicitNullMaximumDependencyDepthButDefaultsAnEmptyObject() { .getValue()); } + @Test + void invalidMaximumDepthDoesNotEmitTheSelfDependentRootAsAPrerequisite() { + final Map flags = new HashMap<>(); + flags.put("root", stringFlag("root", "on", dependencyRule("root", "on"), false)); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator() + .evaluateWithTrace( + config(flags, new EvaluatorParams(null, true)), + String.class, + "root", + "default", + new MutableContext("u")); + + assertEquals(ErrorCode.PARSE_ERROR, evaluation.details.getErrorCode()); + assertTrue(evaluation.dependencyEvaluations.isEmpty()); + assertTrue(evaluation.dependencyExposures.isEmpty()); + } + @Test void preservesWideDependencyExposureOrderWithoutDuplicates() { final int dependencyCount = 512; From e59c6b745f65da9b402e4ce23f6e5a474fd606b9 Mon Sep 17 00:00:00 2001 From: "tyler.potter" Date: Wed, 4 Nov 2026 21:32:59 +0000 Subject: [PATCH 06/12] fix: pin dependent flag evaluation lifecycles Pin configuration snapshots across client hooks and structurally memoize shared exposure traces. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- dd-openfeature/README.md | 9 + .../DependentFlagClientBenchmark.java | 29 ++- .../DependentFlagEvaluatorBenchmark.java | 33 +++ .../com/datadog/openfeature/DDEvaluator.java | 199 ++++++++++-------- .../openfeature/ExposureLoggingHook.java | 21 +- .../openfeature/FlagEvalLoggingHook.java | 21 +- .../openfeature/FlagEvalMetricsHook.java | 20 +- .../internal/evaluation/FlagEvaluator.java | 117 ++++++---- .../EvaluationTelemetryOptOutTest.java | 8 + .../openfeature/ExposureLoggingHookTest.java | 82 +++++++- .../DependentFlagEvaluatorTest.java | 34 +++ 11 files changed, 426 insertions(+), 147 deletions(-) diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md index e710c21e44f..6a14e6bfca0 100644 --- a/dd-openfeature/README.md +++ b/dd-openfeature/README.md @@ -134,6 +134,15 @@ The provider then sends no exposures to Datadog. `ExposureHook` callbacks still This setting stops exposures only. Flag evaluation events have their own setting, `DD_FLAGGING_EVALUATION_COUNTS_ENABLED`. The Java provider does not read dd-trace-py's `DD_FFE_INTAKE_ENABLED`. +For dependent flags, the evaluator finishes the graph before the provider's OpenFeature client hooks +publish prerequisite evaluation events. Those events are parallel to the existing root evaluation +telemetry and are published synchronously before the client evaluation call returns. Evaluation +telemetry is best effort and includes reached prerequisites even when the root fails. Prerequisite +exposures remain transactional: the hooks publish them only when the complete root graph succeeds. + +Call through an OpenFeature client when this telemetry is required. A direct call to the provider +returns resolution details only; it does not run the client-hook sidecar telemetry lifecycle. + ## Per-subject experimentation opt-out Set `DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE=immediate` before constructing the provider. diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/DependentFlagClientBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/DependentFlagClientBenchmark.java index 6b7219cc32f..19ac5c952cc 100644 --- a/dd-openfeature/src/jmh/java/com/datadog/openfeature/DependentFlagClientBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/DependentFlagClientBenchmark.java @@ -104,7 +104,7 @@ public void tearDown() { private static ServerConfiguration configuration(final int dependencyCount) { final Map flags = new HashMap<>(); - flags.put("ordinary", integerFlag("ordinary", null, false)); + flags.put("ordinary", ordinaryFlagWithUnreachableRules(128)); final List conditions = new ArrayList<>(); for (int i = 0; i < dependencyCount; i++) { final String key = "dependency-" + i; @@ -115,6 +115,33 @@ private static ServerConfiguration configuration(final int dependencyCount) { return new ServerConfiguration("", "", false, null, flags); } + private static Flag ordinaryFlagWithUnreachableRules(final int ruleCount) { + final Map variants = new HashMap<>(); + variants.put("selected", new Variant("selected", 1)); + final List allocations = new ArrayList<>(); + for (int i = 0; i < ruleCount; i++) { + final ConditionConfiguration condition = + new ConditionConfiguration(ONE_OF, "missing-" + i, singletonList("match")); + allocations.add( + new Allocation( + "unreachable-" + i, + singletonList(new Rule(singletonList(condition))), + null, + null, + singletonList(new Split(emptyList(), "selected", emptyMap(), 1)), + false)); + } + allocations.add( + new Allocation( + "selected-allocation", + null, + null, + null, + singletonList(new Split(emptyList(), "selected", emptyMap(), 1)), + false)); + return new Flag("ordinary", true, ValueType.INTEGER, variants, allocations); + } + private static Flag integerFlag(final String key, final Rule rule, final boolean doLog) { final Map variants = new HashMap<>(); variants.put("selected", new Variant("selected", 1)); diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorBenchmark.java index 8577b36aa4d..3f645149f6a 100644 --- a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/evaluation/DependentFlagEvaluatorBenchmark.java @@ -45,6 +45,7 @@ public class DependentFlagEvaluatorBenchmark { private FlagEvaluator evaluator; private ServerConfiguration configuration; + private ServerConfiguration sharedSubgraphConfiguration; private MutableContext context; @Setup @@ -60,6 +61,7 @@ public void setUp() { } flags.put("root", flag("root", new Rule(conditions))); configuration = new ServerConfiguration("", "", false, null, flags); + sharedSubgraphConfiguration = sharedSubgraphConfiguration(dependencyCount); } @Benchmark @@ -67,6 +69,37 @@ public FlagEvaluator.EvaluationResult evaluateWideGraph() { return evaluator.evaluateWithTrace(configuration, String.class, "root", "default", context); } + @Benchmark + public FlagEvaluator.EvaluationResult evaluateSharedSubgraph() { + return evaluator.evaluateWithTrace( + sharedSubgraphConfiguration, String.class, "root", "default", context); + } + + private static ServerConfiguration sharedSubgraphConfiguration(final int width) { + final Map flags = new HashMap<>(); + final List leafConditions = new ArrayList<>(); + for (int i = 0; i < width; i++) { + final String leaf = "leaf-" + i; + flags.put(leaf, flag(leaf, null)); + leafConditions.add(new ConditionConfiguration(ONE_OF, null, leaf, singletonList("on"))); + } + flags.put("hub", flag("hub", new Rule(leafConditions))); + final List parentConditions = new ArrayList<>(); + for (int i = 0; i < width; i++) { + final String parent = "parent-" + i; + flags.put( + parent, + flag( + parent, + new Rule( + singletonList( + new ConditionConfiguration(ONE_OF, null, "hub", singletonList("on")))))); + parentConditions.add(new ConditionConfiguration(ONE_OF, null, parent, singletonList("on"))); + } + flags.put("root", flag("root", new Rule(parentConditions))); + return new ServerConfiguration("", "", false, null, flags); + } + private static Flag flag(final String key, final Rule rule) { final Map variants = new HashMap<>(); variants.put("on", new Variant("on", "on-value")); 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 4717514a202..d98cb62a562 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -25,7 +25,6 @@ import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.ValueType; import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.ProviderEvaluation; @@ -33,12 +32,13 @@ import dev.openfeature.sdk.Value; import java.time.Instant; import java.util.AbstractMap; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.Deque; import java.util.HashMap; +import java.util.HashSet; import java.util.IdentityHashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; @@ -47,6 +47,8 @@ class DDEvaluator implements Evaluator, Consumer { + static final String DEPENDENCY_TELEMETRY_SCOPE = "datadog.dependencyTelemetryScope"; + /** * Maximum evaluation-context nesting depth captured on the hot path. Recursion runs on the * caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep @@ -96,13 +98,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 ConfigurationSnapshot configurationSnapshot = ConfigurationSnapshot.EMPTY; + // Dependency telemetry is sidecar telemetry parallel to the existing OpenFeature client-hook + // telemetry. The exposure hook pins one configuration snapshot before provider evaluation; + // evaluation hooks publish the returned batch after graph completion and before the client call + // returns; finallyAfter closes the scope even when application after hooks fail. Direct provider + // calls have no scope and emit no sidecar telemetry. A stack isolates nested client evaluations. + private final ThreadLocal> dependencyTelemetryScopes = new ThreadLocal<>(); private volatile FeatureFlagsRuntime runtime; DDEvaluator(final Runnable configCallback, final AgentBridge bridge, final Settings settings) { @@ -142,7 +144,7 @@ public boolean initialize( @Override public boolean hasConfiguration() { - return this.configuration.get() != null; + return this.configurationSnapshot.config != null; } @Override @@ -165,6 +167,8 @@ FeatureFlagsRuntime runtime() { @Override public void accept(final ServerConfiguration config) { + final ConfigurationSnapshot snapshot = ConfigurationSnapshot.create(config); + this.configurationSnapshot = snapshot; this.configuration.set(config); if (config != null) { this.configuration.markReady(); @@ -180,9 +184,17 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { - final DependencyTelemetryToken token = claimDependencyTelemetry(key, context); + final Object scope = claimEvaluationScope(key, context); + final DependencyTelemetryToken token = + scope instanceof DependencyTelemetryToken ? (DependencyTelemetryToken) scope : null; + final ConfigurationSnapshot snapshot = + token != null + ? token.snapshot + : scope instanceof ConfigurationSnapshot + ? (ConfigurationSnapshot) scope + : configurationSnapshot; final FlagEvaluator.EvaluationResult evaluation = - core.evaluateWithTrace(configuration.get(), target, key, defaultValue, context); + core.evaluateWithTrace(snapshot.config, target, key, defaultValue, context); final ProviderEvaluation result = evaluation.details; stageDependencyTelemetry(token, evaluation); return result; @@ -391,108 +403,73 @@ void recordDependencyMetrics( } } - DependencyTelemetryToken beginDependencyExposureScope( + Object beginDependencyExposureScope( final String rootFlagKey, final EvaluationContext evaluationContext) { - if (!hasDependencyConditions(rootFlagKey)) { - return null; - } - Deque scopes = dependencyTelemetryScopes.get(); + final ConfigurationSnapshot snapshot = configurationSnapshot; + final Object scope = + snapshot.dependencyFlags.contains(rootFlagKey) + ? new DependencyTelemetryToken(snapshot, rootFlagKey, evaluationContext) + : snapshot; + Deque scopes = dependencyTelemetryScopes.get(); if (scopes == null) { - scopes = new LinkedList<>(); + scopes = new ArrayDeque<>(); dependencyTelemetryScopes.set(scopes); } - final DependencyTelemetryToken token = - new DependencyTelemetryToken(this, rootFlagKey, evaluationContext); - scopes.push(token); - return token; + scopes.push(scope); + return scope; } - private boolean hasDependencyConditions(final String rootFlagKey) { - final ServerConfiguration config = configuration.get(); - final Flag flag = - config == null || config.flags == null || rootFlagKey == null - ? null - : config.flags.get(rootFlagKey); - if (flag == null || flag.allocations == null) { - return false; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null || allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition != null && condition.flagEvaluationKey != null) { - return true; - } - } - } - } - return false; - } - - List endDependencyExposureScope( - final DependencyTelemetryToken token) { - final Deque scopes = dependencyTelemetryScopes.get(); - if (token == null || scopes == null || scopes.isEmpty() || scopes.peek() != token) { + List endDependencyExposureScope(final Object scope) { + final Deque scopes = dependencyTelemetryScopes.get(); + if (scope == null || scopes == null || scopes.isEmpty() || scopes.peek() != scope) { return Collections.emptyList(); } - final List exposures = scopes.pop().exposures; + scopes.pop(); if (scopes.isEmpty()) { dependencyTelemetryScopes.remove(); } - return exposures; + return scope instanceof DependencyTelemetryToken + ? ((DependencyTelemetryToken) scope).exposures + : Collections.emptyList(); } List endDependencyExposureScope() { - final Deque scopes = dependencyTelemetryScopes.get(); + final Deque scopes = dependencyTelemetryScopes.get(); return scopes == null || scopes.isEmpty() ? Collections.emptyList() : endDependencyExposureScope(scopes.peek()); } - void endUnclaimedDependencyExposureScope() { - final Deque scopes = dependencyTelemetryScopes.get(); - if (scopes == null || scopes.isEmpty() || scopes.peek().claimed) { - return; - } - endDependencyExposureScope(scopes.peek()); + DependencyTelemetryToken dependencyTelemetryToken(final Object scope) { + return scope instanceof DependencyTelemetryToken && ((DependencyTelemetryToken) scope).sealed + ? (DependencyTelemetryToken) scope + : null; } - DependencyTelemetryToken dependencyTelemetryToken(final FlagEvaluationDetails details) { - if (details == null) { - return null; - } - final Deque scopes = dependencyTelemetryScopes.get(); - if (scopes == null || scopes.isEmpty()) { - return null; - } - final DependencyTelemetryToken token = scopes.peek(); - return token.sealed - && token.rootMetadata != null - && token.rootMetadata == details.getFlagMetadata() - ? token - : null; + Object currentDependencyTelemetryScope() { + final Deque scopes = dependencyTelemetryScopes.get(); + return scopes == null || scopes.isEmpty() ? null : scopes.peek(); } DependencyEvaluationTrace dependencyEvaluationTrace(final DependencyTelemetryToken token) { - final Deque scopes = dependencyTelemetryScopes.get(); + final Deque scopes = dependencyTelemetryScopes.get(); if (token == null || scopes == null || scopes.isEmpty() || scopes.peek() != token) { return DependencyEvaluationTrace.EMPTY; } return token.sealed ? token.evaluationTrace : DependencyEvaluationTrace.EMPTY; } - private DependencyTelemetryToken claimDependencyTelemetry( + private Object claimEvaluationScope( final String rootFlagKey, final EvaluationContext evaluationContext) { - final Deque scopes = dependencyTelemetryScopes.get(); + final Deque scopes = dependencyTelemetryScopes.get(); if (scopes == null || scopes.isEmpty()) { return null; } - final DependencyTelemetryToken token = scopes.peek(); + final Object scope = scopes.peek(); + if (!(scope instanceof DependencyTelemetryToken)) { + return scope; + } + final DependencyTelemetryToken token = (DependencyTelemetryToken) scope; if (token.claimed || !token.matches(rootFlagKey, evaluationContext)) { return null; } @@ -509,7 +486,6 @@ private void stageDependencyTelemetry( // eligible candidates. This prevents a later direct or reentrant evaluation from filling the // scope before finallyAfter consumes it. token.sealed = true; - token.rootMetadata = evaluation.details.getFlagMetadata(); if (!evaluation.dependencyEvaluations.isEmpty()) { token.evaluationTrace = new DependencyEvaluationTrace( @@ -601,20 +577,19 @@ private DependencyEvaluationTrace( } static final class DependencyTelemetryToken { - final DDEvaluator evaluator; + private final ConfigurationSnapshot snapshot; private final String rootFlagKey; private final EvaluationContext evaluationContext; private DependencyEvaluationTrace evaluationTrace = DependencyEvaluationTrace.EMPTY; private List exposures = Collections.emptyList(); - private ImmutableMetadata rootMetadata; private boolean claimed; private boolean sealed; private DependencyTelemetryToken( - final DDEvaluator evaluator, + final ConfigurationSnapshot snapshot, final String rootFlagKey, final EvaluationContext evaluationContext) { - this.evaluator = evaluator; + this.snapshot = snapshot; this.rootFlagKey = rootFlagKey; this.evaluationContext = evaluationContext; } @@ -627,6 +602,58 @@ private boolean matches( } } + private static final class ConfigurationSnapshot { + private static final ConfigurationSnapshot EMPTY = + new ConfigurationSnapshot(null, Collections.emptySet()); + + private final ServerConfiguration config; + private final Set dependencyFlags; + + private ConfigurationSnapshot( + final ServerConfiguration config, final Set dependencyFlags) { + this.config = config; + this.dependencyFlags = dependencyFlags; + } + + private static ConfigurationSnapshot create(final ServerConfiguration config) { + if (config == null || config.flags == null || config.flags.isEmpty()) { + return config == null ? EMPTY : new ConfigurationSnapshot(config, Collections.emptySet()); + } + final Set dependencyFlags = new HashSet<>(); + for (final Map.Entry entry : config.flags.entrySet()) { + final Flag flag = entry.getValue(); + if (flag == null || flag.allocations == null) { + continue; + } + boolean found = false; + for (final Allocation allocation : flag.allocations) { + if (allocation == null || allocation.rules == null) { + continue; + } + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition != null && condition.flagEvaluationKey != null) { + dependencyFlags.add(entry.getKey()); + found = true; + break; + } + } + if (found) { + break; + } + } + if (found) { + break; + } + } + } + return new ConfigurationSnapshot(config, Collections.unmodifiableSet(dependencyFlags)); + } + } + static AbstractMap flattenContext(final EvaluationContext context) { return flattenValues(snapshotValues(context)); } @@ -687,7 +714,7 @@ static AbstractMap flattenValues(final Map values final HashMap result = new HashMap<>(); final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); for (final Map.Entry root : values.entrySet()) { - final Deque deque = new LinkedList<>(); + final Deque deque = new ArrayDeque<>(); deque.push(new FlattenEntry(root.getKey(), root.getValue())); while (!deque.isEmpty()) { final FlattenEntry entry = deque.pop(); 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 df67f64a395..b74208c2be9 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -48,9 +48,11 @@ public Optional before( final HookContext ctx, final Map hints) { try { final DDEvaluator evaluator = this.evaluatorSupplier.get(); - if (evaluator != null) { - evaluator.beginDependencyExposureScope( - ctx == null ? null : ctx.getFlagKey(), ctx == null ? null : ctx.getCtx()); + if (evaluator != null && ctx != null) { + final Object scope = evaluator.beginDependencyExposureScope(ctx.getFlagKey(), ctx.getCtx()); + if (ctx.getHookData() != null) { + ctx.getHookData().set(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE, scope); + } } } catch (final RuntimeException | LinkageError e) { warnOnce(e); @@ -65,19 +67,18 @@ public void finallyAfter( final Map hints) { try { final DDEvaluator evaluator = this.evaluatorSupplier.get(); - final DDEvaluator.DependencyTelemetryToken token = - evaluator == null ? null : evaluator.dependencyTelemetryToken(details); + final Object scope = + ctx == null || ctx.getHookData() == null + ? evaluator == null ? null : evaluator.currentDependencyTelemetryScope() + : ctx.getHookData().get(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE); final List dependencyExposures = - token == null + evaluator == null ? java.util.Collections . emptyList() - : evaluator.endDependencyExposureScope(token); - if (evaluator != null && token == null) { - evaluator.endUnclaimedDependencyExposureScope(); - } + : evaluator.endDependencyExposureScope(scope); if (details == null) { log.trace("Feature Flagging exposure suppressed: evaluation failed or details unavailable"); return; 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 3d5861ad898..2216cb60300 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -8,12 +8,14 @@ import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; +import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.ImmutableMetadata; import java.util.Collections; import java.util.Map; +import java.util.Optional; import java.util.function.Supplier; /** @@ -54,6 +56,19 @@ class FlagEvalLoggingHook implements Hook { this.evaluatorSupplier = evaluatorSupplier; } + @Override + public Optional before( + final HookContext ctx, final Map hints) { + final DDEvaluator evaluator = evaluatorSupplier.get(); + if (evaluator != null && ctx != null && ctx.getHookData() != null) { + final Object scope = evaluator.currentDependencyTelemetryScope(); + if (scope != null) { + ctx.getHookData().set(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE, scope); + } + } + return Optional.empty(); + } + /** * Capture + non-blocking enqueue only; no flattening, aggregation, or I/O. Runs at the finally * stage so it covers success, error, and default-value paths. @@ -79,7 +94,11 @@ public void finallyAfter( if (evaluator != null && ctx != null) { try { evaluator.publishDependencyEvaluations( - evaluator.dependencyEvaluationTrace(evaluator.dependencyTelemetryToken(details)), + evaluator.dependencyEvaluationTrace( + evaluator.dependencyTelemetryToken( + ctx.getHookData() == null + ? evaluator.currentDependencyTelemetryScope() + : ctx.getHookData().get(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE))), ctx.getCtx(), w); } catch (final RuntimeException | LinkageError ignored) { 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 69e1aa9b752..6ce6c923887 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java @@ -1,11 +1,13 @@ package com.datadog.openfeature; import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.ImmutableMetadata; import java.util.Map; +import java.util.Optional; import java.util.function.Supplier; class FlagEvalMetricsHook implements Hook { @@ -23,6 +25,18 @@ class FlagEvalMetricsHook implements Hook { this.evaluatorSupplier = evaluatorSupplier; } + @Override + public Optional before(HookContext ctx, Map hints) { + final DDEvaluator evaluator = evaluatorSupplier.get(); + if (evaluator != null && ctx != null && ctx.getHookData() != null) { + final Object scope = evaluator.currentDependencyTelemetryScope(); + if (scope != null) { + ctx.getHookData().set(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE, scope); + } + } + return Optional.empty(); + } + @Override public void finallyAfter( HookContext ctx, FlagEvaluationDetails details, Map hints) { @@ -33,7 +47,11 @@ public void finallyAfter( final DDEvaluator evaluator = evaluatorSupplier.get(); if (evaluator != null && ctx != null) { evaluator.recordDependencyMetrics( - evaluator.dependencyEvaluationTrace(evaluator.dependencyTelemetryToken(details)), + evaluator.dependencyEvaluationTrace( + evaluator.dependencyTelemetryToken( + ctx.getHookData() == null + ? evaluator.currentDependencyTelemetryScope() + : ctx.getHookData().get(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE))), metrics); } } catch (final RuntimeException | LinkageError ignored) { 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 394189dc1f8..f8fd273d6c2 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 @@ -39,8 +39,10 @@ import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -787,8 +789,7 @@ private final class DependencyEvaluationSession { 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 final Set exposureKeys = new HashSet<>(); + private final List selectedExposureTraces = new ArrayList<>(); private Integer maxDependencyDepth; private DependencyEvaluationSession(final String rootFlagKey, final EvaluationState state) { @@ -799,19 +800,18 @@ private DependencyEvaluationSession(final String rootFlagKey, final EvaluationSt private ExposureCheckpoint checkpoint() { return new ExposureCheckpoint( - exposures.size(), - frames.isEmpty() ? 0 : frames.get(frames.size() - 1).exposureTrace.size()); + selectedExposureTraces.size(), + frames.isEmpty() ? 0 : frames.get(frames.size() - 1).childExposureTraces.size()); } private void rollback(final ExposureCheckpoint checkpoint) { - while (exposures.size() > checkpoint.exposureCount) { - exposureKeys.remove(exposures.remove(exposures.size() - 1).flagKey); + while (selectedExposureTraces.size() > checkpoint.exposureTraceCount) { + selectedExposureTraces.remove(selectedExposureTraces.size() - 1); } if (!frames.isEmpty()) { final DependencyFrame frame = frames.get(frames.size() - 1); - while (frame.exposureTrace.size() > checkpoint.frameExposureCount) { - frame.exposureKeys.remove( - frame.exposureTrace.remove(frame.exposureTrace.size() - 1).flagKey); + while (frame.childExposureTraces.size() > checkpoint.frameExposureTraceCount) { + frame.childExposureTraces.remove(frame.childExposureTraces.size() - 1); } } } @@ -821,7 +821,7 @@ private EvaluationResult complete( return new EvaluationResult<>( details, evaluations, - commitExposures ? exposures : emptyList(), + commitExposures ? flattenExposureTraces() : emptyList(), state.evaluationTimestampMs); } @@ -894,9 +894,8 @@ private String resolve(final String flagKey, final int depth) { throw new DependencyGraphException( ErrorCode.GENERAL, "prerequisite flag evaluation returned no variant"); } - final List exposureTrace = new ArrayList<>(frame.exposureTrace); - final Set exposureTraceKeys = new HashSet<>(frame.exposureKeys); - appendUnique(exposureTrace, exposureTraceKeys, new DependencyExposure(flagKey, details)); + final ExposureTrace exposureTrace = + new ExposureTrace(frame.childExposureTraces, new DependencyExposure(flagKey, details)); final MemoizedDependency result = new MemoizedDependency(details, frame.maxReachedDepth - depth, exposureTrace); memo.put(flagKey, result); @@ -951,23 +950,48 @@ private void recordReachedDepthOnParent(final int depth) { } } - private void recordExposureTrace(final List trace) { - for (final DependencyExposure candidate : trace) { - appendUnique(exposures, exposureKeys, candidate); - if (!frames.isEmpty()) { - final DependencyFrame frame = frames.get(frames.size() - 1); - appendUnique(frame.exposureTrace, frame.exposureKeys, candidate); - } + private void recordExposureTrace(final ExposureTrace trace) { + if (frames.isEmpty()) { + selectedExposureTraces.add(trace); + } else { + frames.get(frames.size() - 1).childExposureTraces.add(trace); } } - private void recordExposureTraceOnParent(final List trace) { - for (final DependencyExposure candidate : trace) { - appendUnique(exposures, exposureKeys, candidate); - if (frames.size() > 1) { - final DependencyFrame parent = frames.get(frames.size() - 2); - appendUnique(parent.exposureTrace, parent.exposureKeys, candidate); - } + private void recordExposureTraceOnParent(final ExposureTrace trace) { + if (frames.size() > 1) { + frames.get(frames.size() - 2).childExposureTraces.add(trace); + } else { + selectedExposureTraces.add(trace); + } + } + + private List flattenExposureTraces() { + if (selectedExposureTraces.isEmpty()) { + return emptyList(); + } + final List result = new ArrayList<>(); + final Set exposureKeys = new HashSet<>(); + final Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + for (final ExposureTrace trace : selectedExposureTraces) { + flattenExposureTrace(trace, visited, exposureKeys, result); + } + return result; + } + + private void flattenExposureTrace( + final ExposureTrace trace, + final Set visited, + final Set exposureKeys, + final List result) { + if (!visited.add(trace)) { + return; + } + for (final ExposureTrace child : trace.children) { + flattenExposureTrace(child, visited, exposureKeys, result); + } + if (exposureKeys.add(trace.exposure.flagKey)) { + result.add(trace.exposure); } } } @@ -995,20 +1019,10 @@ private ProviderEvaluation evaluateDeclaredFlag( } } - private static void appendUnique( - final List target, - final Set keys, - final DependencyExposure candidate) { - if (keys.add(candidate.flagKey)) { - target.add(candidate); - } - } - private static final class DependencyFrame { private final int depth; private int maxReachedDepth; - private final List exposureTrace = new ArrayList<>(); - private final Set exposureKeys = new HashSet<>(); + private final List childExposureTraces = new ArrayList<>(); private DependencyFrame(final int depth) { this.depth = depth; @@ -1018,24 +1032,37 @@ private DependencyFrame(final int depth) { private static final class ExposureCheckpoint { private static final ExposureCheckpoint EMPTY = new ExposureCheckpoint(0, 0); - private final int exposureCount; - private final int frameExposureCount; + private final int exposureTraceCount; + private final int frameExposureTraceCount; + + private ExposureCheckpoint(final int exposureTraceCount, final int frameExposureTraceCount) { + this.exposureTraceCount = exposureTraceCount; + this.frameExposureTraceCount = frameExposureTraceCount; + } + } + + private static final class ExposureTrace { + private final List children; + private final DependencyExposure exposure; - private ExposureCheckpoint(final int exposureCount, final int frameExposureCount) { - this.exposureCount = exposureCount; - this.frameExposureCount = frameExposureCount; + private ExposureTrace(final List children, final DependencyExposure exposure) { + this.children = + children.isEmpty() + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(children)); + this.exposure = exposure; } } private static final class MemoizedDependency { private final ProviderEvaluation details; private final int descendantDepth; - private final List exposureTrace; + private final ExposureTrace exposureTrace; private MemoizedDependency( final ProviderEvaluation details, final int descendantDepth, - final List exposureTrace) { + final ExposureTrace exposureTrace) { this.details = details; this.descendantDepth = descendantDepth; this.exposureTrace = exposureTrace; 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 d3e62ca1ee4..4ba32e128c3 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -640,7 +640,15 @@ public void after( assertEquals(ErrorCode.GENERAL, details.getErrorCode()); assertEquals(0, details.getValue()); + assertEquals(2, this.events.size(), "dependency and failed root telemetry"); + assertEquals("dependency", this.events.get(0).flagKey); + assertEquals("root", this.events.get(1).flagKey); assertEquals(0, this.exposures.size()); + + this.client.getIntegerDetails("root", 0, context("nested-subject", new Value(false))); + assertEquals(4, this.events.size(), "the failed lifecycle must not retain its scope"); + assertEquals("dependency", this.events.get(2).flagKey); + assertEquals("root", this.events.get(3).flagKey); } @Test 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 e09b26adf99..64552dc8d62 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java @@ -8,6 +8,7 @@ import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -32,6 +33,7 @@ import com.datadog.openfeature.internal.ufc.ValueType; import com.datadog.openfeature.internal.ufc.Variant; import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.DefaultHookData; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; @@ -188,7 +190,9 @@ void doesNotStageIneligibleDependencyExposures() throws Exception { void ordinaryFlagLifecycleDoesNotCreateADependencyScope() { final HookContext context = hookContext(); - assertNull(this.evaluator.beginDependencyExposureScope(context.getFlagKey(), context.getCtx())); + final Object scope = + this.evaluator.beginDependencyExposureScope(context.getFlagKey(), context.getCtx()); + assertNull(this.evaluator.dependencyTelemetryToken(scope)); assertTrue(this.evaluator.endDependencyExposureScope().isEmpty()); } @@ -231,6 +235,7 @@ void skippedNestedBeforeCannotReadTheOuterBatchForTheSameFlagAndContext() { .type(FlagValueType.INTEGER) .defaultValue(0) .ctx(context) + .hookData(new DefaultHookData()) .build(); final HookContext skippedNested = HookContext.builder() @@ -238,6 +243,7 @@ void skippedNestedBeforeCannotReadTheOuterBatchForTheSameFlagAndContext() { .type(FlagValueType.INTEGER) .defaultValue(0) .ctx(context) + .hookData(new DefaultHookData()) .build(); final Map outerHints = new HashMap<>(); final Map skippedNestedHints = new HashMap<>(); @@ -255,8 +261,11 @@ void skippedNestedBeforeCannotReadTheOuterBatchForTheSameFlagAndContext() { .errorCode(ErrorCode.GENERAL) .build(); - assertNull(dependent.dependencyTelemetryToken(skippedNestedDetails)); - assertNotNull(dependent.dependencyTelemetryToken(outerDetails)); + final Object outerScope = outer.getHookData().get(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE); + assertNull( + dependent.dependencyTelemetryToken( + skippedNested.getHookData().get(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE))); + assertNotNull(dependent.dependencyTelemetryToken(outerScope)); final List events = new java.util.ArrayList<>(); final FlagEvaluationWriter writer = @@ -279,6 +288,7 @@ public void enqueue(final FlagEvalEvent event) { }; final FlagEvalLoggingHook evaluationHook = new FlagEvalLoggingHook<>(() -> writer, () -> dependent); + evaluationHook.before(outer, outerHints); evaluationHook.finallyAfter(skippedNested, skippedNestedDetails, skippedNestedHints); evaluationHook.finallyAfter(outer, outerDetails, outerHints); @@ -310,6 +320,48 @@ void dependencyExposuresReuseOneDetachedContextSnapshot() throws Exception { assertEquals("us-east-1", dependent.sentToDatadog.get(0).subject.attributes.get("region")); } + @Test + void configurationRefreshCannotChangeTheGraphInsideAClientHookLifecycle() { + final ExposureTestEvaluator dependent = + new ExposureTestEvaluator(ordinaryRootConfiguration(), true); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> dependent); + final MutableContext evaluationContext = new MutableContext("subject"); + final HookContext hookContext = + HookContext.builder() + .flagKey("root") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(evaluationContext) + .hookData(new DefaultHookData()) + .build(); + + hook.before(hookContext, emptyMap()); + dependent.accept(dependentConfiguration(true)); + final ProviderEvaluation pinned = + dependent.evaluate(Integer.class, "root", 0, evaluationContext); + hook.finallyAfter(hookContext, FlagEvaluationDetails.from(pinned, "root"), emptyMap()); + + assertEquals(2, pinned.getValue(), "evaluation uses the snapshot pinned by before"); + assertTrue(dependent.sentToDatadog.isEmpty()); + + final HookContext refreshedContext = + HookContext.builder() + .flagKey("root") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(evaluationContext) + .hookData(new DefaultHookData()) + .build(); + hook.before(refreshedContext, emptyMap()); + final ProviderEvaluation refreshed = + dependent.evaluate(Integer.class, "root", 0, evaluationContext); + hook.finallyAfter(refreshedContext, FlagEvaluationDetails.from(refreshed, "root"), emptyMap()); + + assertEquals(1, refreshed.getValue()); + assertEquals(1, dependent.sentToDatadog.size()); + assertEquals("dependency", dependent.sentToDatadog.get(0).flag.key); + } + @Test void directProviderEvaluationDoesNotRetainOrExposeDependencyState() throws Exception { final ExposureTestEvaluator dependent = @@ -355,6 +407,7 @@ void reentrantDirectEvaluationCannotReplaceTheOuterRootsDependencyExposures() { .type(FlagValueType.INTEGER) .defaultValue(0) .ctx(outerContext) + .hookData(new DefaultHookData()) .build(); final Map hints = new HashMap<>(); @@ -382,6 +435,7 @@ void unmatchedNestedFinallyCannotPopTheOuterDependencyExposureScope() { .type(FlagValueType.INTEGER) .defaultValue(0) .ctx(outerContext) + .hookData(new DefaultHookData()) .build(); final HookContext innerHookContext = HookContext.builder() @@ -389,6 +443,7 @@ void unmatchedNestedFinallyCannotPopTheOuterDependencyExposureScope() { .type(FlagValueType.INTEGER) .defaultValue(0) .ctx(new MutableContext("subject-b")) + .hookData(new DefaultHookData()) .build(); final Map outerHints = new HashMap<>(); @@ -653,6 +708,27 @@ private static ServerConfiguration dependentConfiguration(final boolean dependen return new ServerConfiguration("", "", false, null, flags); } + private static ServerConfiguration ordinaryRootConfiguration() { + final Map variations = new HashMap<>(); + variations.put("selected", new Variant("selected", 2)); + final Allocation allocation = + new Allocation( + "ordinary-root", + null, + null, + null, + singletonList(new Split(emptyList(), "selected", emptyMap(), 1)), + false); + return new ServerConfiguration( + "", + "", + false, + null, + singletonMap( + "root", + new Flag("root", true, ValueType.INTEGER, variations, singletonList(allocation)))); + } + private static ServerConfiguration reentrantDependentConfiguration() { final Map flags = new HashMap<>(); addDependentFlagPair(flags, "root-a", "dependency-a", true); 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 index eca9bbe51cf..2416b9548ab 100644 --- 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 @@ -339,6 +339,40 @@ void preservesWideDependencyExposureOrderWithoutDuplicates() { } } + @Test + void structurallyReusesExposureTraceForManyParentsOfASharedHub() { + final int width = 128; + final Map flags = new HashMap<>(); + final List leafConditions = new ArrayList<>(); + for (int i = 0; i < width; i++) { + final String leaf = "leaf-" + i; + flags.put(leaf, stringFlag(leaf, "on", null, true)); + leafConditions.add(new ConditionConfiguration(ONE_OF, null, leaf, singletonList("on"))); + } + flags.put("hub", stringFlag("hub", "on", new Rule(leafConditions), true)); + final List parentConditions = new ArrayList<>(); + for (int i = 0; i < width; i++) { + final String parent = "parent-" + i; + flags.put(parent, stringFlag(parent, "on", dependencyRule("hub", "on"), true)); + parentConditions.add(new ConditionConfiguration(ONE_OF, null, parent, singletonList("on"))); + } + flags.put("root", stringFlag("root", "on", new Rule(parentConditions), false)); + + final FlagEvaluator.EvaluationResult evaluation = + evaluator() + .evaluateWithTrace( + config(flags, null), String.class, "root", "default", new MutableContext("u")); + + assertEquals("on-value", evaluation.details.getValue()); + assertEquals(width * 2 + 1, evaluation.dependencyEvaluations.size()); + assertEquals(width * 2 + 1, evaluation.dependencyExposures.size()); + assertEquals("leaf-0", evaluation.dependencyExposures.get(0).flagKey); + assertEquals("leaf-127", evaluation.dependencyExposures.get(width - 1).flagKey); + assertEquals("hub", evaluation.dependencyExposures.get(width).flagKey); + assertEquals("parent-0", evaluation.dependencyExposures.get(width + 1).flagKey); + assertEquals("parent-127", evaluation.dependencyExposures.get(width * 2).flagKey); + } + @Test void rejectsNonStringDependencyMembershipOperandsWithParseError() throws Exception { final ServerConfiguration config = From a30cc4dc1b9f9addf7a57a258b90cb8d04503ba5 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 4 Nov 2026 15:50:37 -0600 Subject: [PATCH 07/12] fix(openfeature): isolate evaluation hook lifecycles Claim pinned snapshots once and preserve wrapped telemetry setup. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../com/datadog/openfeature/DDEvaluator.java | 49 +++++++++++++++---- .../EvaluationTelemetryOptOutHook.java | 2 +- .../EvaluationTelemetryOptOutTest.java | 23 +++++++++ .../openfeature/ExposureLoggingHookTest.java | 6 +++ 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index d98cb62a562..cdec0a9d27e 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -190,8 +190,8 @@ public ProviderEvaluation evaluate( final ConfigurationSnapshot snapshot = token != null ? token.snapshot - : scope instanceof ConfigurationSnapshot - ? (ConfigurationSnapshot) scope + : scope instanceof EvaluationScope + ? ((EvaluationScope) scope).snapshot : configurationSnapshot; final FlagEvaluator.EvaluationResult evaluation = core.evaluateWithTrace(snapshot.config, target, key, defaultValue, context); @@ -409,7 +409,7 @@ Object beginDependencyExposureScope( final Object scope = snapshot.dependencyFlags.contains(rootFlagKey) ? new DependencyTelemetryToken(snapshot, rootFlagKey, evaluationContext) - : snapshot; + : new EvaluationScope(snapshot, rootFlagKey, evaluationContext); Deque scopes = dependencyTelemetryScopes.get(); if (scopes == null) { scopes = new ArrayDeque<>(); @@ -466,15 +466,23 @@ private Object claimEvaluationScope( return null; } final Object scope = scopes.peek(); - if (!(scope instanceof DependencyTelemetryToken)) { - return scope; + if (scope instanceof DependencyTelemetryToken) { + final DependencyTelemetryToken token = (DependencyTelemetryToken) scope; + if (token.claimed || !token.matches(rootFlagKey, evaluationContext)) { + return null; + } + token.claimed = true; + return token; + } + if (!(scope instanceof EvaluationScope)) { + return null; } - final DependencyTelemetryToken token = (DependencyTelemetryToken) scope; - if (token.claimed || !token.matches(rootFlagKey, evaluationContext)) { + final EvaluationScope evaluationScope = (EvaluationScope) scope; + if (evaluationScope.claimed || !evaluationScope.matches(rootFlagKey, evaluationContext)) { return null; } - token.claimed = true; - return token; + evaluationScope.claimed = true; + return evaluationScope; } private void stageDependencyTelemetry( @@ -602,6 +610,29 @@ private boolean matches( } } + private static final class EvaluationScope { + private final ConfigurationSnapshot snapshot; + private final String rootFlagKey; + private final EvaluationContext evaluationContext; + private boolean claimed; + + private EvaluationScope( + final ConfigurationSnapshot snapshot, + final String rootFlagKey, + final EvaluationContext evaluationContext) { + this.snapshot = snapshot; + this.rootFlagKey = rootFlagKey; + this.evaluationContext = evaluationContext; + } + + private boolean matches( + final String evaluatedFlagKey, final EvaluationContext evaluatedContext) { + return rootFlagKey != null + && rootFlagKey.equals(evaluatedFlagKey) + && evaluationContext == evaluatedContext; + } + } + private static final class ConfigurationSnapshot { private static final ConfigurationSnapshot EMPTY = new ConfigurationSnapshot(null, Collections.emptySet()); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/EvaluationTelemetryOptOutHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/EvaluationTelemetryOptOutHook.java index 0916ea3d5f2..04acfca68f6 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/EvaluationTelemetryOptOutHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/EvaluationTelemetryOptOutHook.java @@ -24,7 +24,7 @@ public Optional before( // Provider before hooks see the merged context, including application before hooks. // HookData belongs to this evaluation, including nested and concurrent calls. context.getHookData().set(DECISION, isOptedOut(context.getCtx())); - return Optional.empty(); + return delegate.before(context, hints); } @Override 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 4ba32e128c3..9b6373b0849 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -528,6 +528,29 @@ void dependencyTelemetryHonorsEnabledProviderOptOut() throws Exception { assertEquals(2, this.exposures.size(), "dependency and root exposures remain independent"); } + @Test + void enabledProviderOptOutIncludesDependencyTelemetryWhenSubjectDoesNotOptOut() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(true), + dependentConfiguration()); + + for (final Value optOut : asList(null, new Value(false))) { + final String subject = optOut == null ? "absent" : "false"; + final FlagEvaluationDetails included = + this.client.getIntegerDetails("root", 0, context(subject, optOut)); + + assertEquals(1, included.getValue()); + } + + assertEquals(4, this.events.size(), "dependency and root events for both subjects"); + assertEquals("dependency", this.events.get(0).flagKey); + assertEquals("root", this.events.get(1).flagKey); + assertEquals("dependency", this.events.get(2).flagKey); + assertEquals("root", this.events.get(3).flagKey); + assertEquals(4, this.metrics.get(), "dependency and root metrics for both subjects"); + assertEquals(4, this.exposures.size()); + } + @Test void dependencyTelemetryIncludesMetricsWhenProviderOptOutIsDisabled() throws Exception { start( 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 64552dc8d62..bf369efb2b0 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java @@ -339,9 +339,15 @@ void configurationRefreshCannotChangeTheGraphInsideAClientHookLifecycle() { dependent.accept(dependentConfiguration(true)); final ProviderEvaluation pinned = dependent.evaluate(Integer.class, "root", 0, evaluationContext); + final ProviderEvaluation unrelated = + dependent.evaluate(String.class, "dependency", "default", evaluationContext); hook.finallyAfter(hookContext, FlagEvaluationDetails.from(pinned, "root"), emptyMap()); assertEquals(2, pinned.getValue(), "evaluation uses the snapshot pinned by before"); + assertEquals( + "on-value", + unrelated.getValue(), + "an unrelated evaluation after the root uses the refreshed configuration"); assertTrue(dependent.sentToDatadog.isEmpty()); final HookContext refreshedContext = From f1976f6aaf8def002254a16cf258fee51c75528b Mon Sep 17 00:00:00 2001 From: "tyler.potter" Date: Thu, 5 Nov 2026 22:03:21 +0000 Subject: [PATCH 08/12] fix(openfeature): address dependent flag feedback Keep prerequisite telemetry and customer exposure callbacks aligned while making dependency parsing forward-compatible. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../com/datadog/openfeature/DDEvaluator.java | 18 ++++- .../com/datadog/openfeature/ExposureHook.java | 70 ++++++++++++++++++- .../openfeature/ExposureLoggingHook.java | 4 +- .../internal/evaluation/FlagEvaluator.java | 54 ++++++++++++-- .../ufc/UniversalFlagConfigParser.java | 8 +-- .../EvaluationTelemetryOptOutTest.java | 51 ++++++++++++++ .../openfeature/ExposureLoggingHookTest.java | 2 +- .../DependentFlagEvaluatorTest.java | 21 ++++-- .../ufc/UniversalFlagConfigParserTest.java | 37 ++++++++++ 9 files changed, 242 insertions(+), 23 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index cdec0a9d27e..b103b856b31 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -25,6 +25,7 @@ import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.ValueType; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.ProviderEvaluation; @@ -526,15 +527,16 @@ private void stageDependencyTelemetry( } } - void recordDependencyExposures( + List> recordDependencyExposures( final List exposures, final EvaluationContext context) { final ExposureDeduplicationCache cache = exposureCache(); if (cache == null || context == null) { - return; + return Collections.emptyList(); } final String targetingKey = context.getTargetingKey(); final boolean exposureDeliveryEnabled = isExposureDeliveryEnabled(); Map contextAttributes = null; + List> details = null; for (final FlagEvaluator.DependencyExposure exposure : exposures) { final ImmutableMetadata metadata = exposure.metadata; final Map values = @@ -549,6 +551,17 @@ void recordDependencyExposures( if (allocationKey == null || variantKey == null) { continue; } + if (details == null) { + details = new ArrayList<>(); + } + details.add( + FlagEvaluationDetails.builder() + .flagKey(exposure.flagKey) + .value(exposure.value) + .variant(variantKey) + .reason(exposure.reason) + .flagMetadata(metadata) + .build()); final Object serialValue = values.get(SPLIT_SERIAL_ID); final Integer serialId = serialValue instanceof Integer ? (Integer) serialValue : null; if (cache.recordIfChanged(exposure.flagKey, targetingKey, allocationKey, variantKey, serialId) @@ -567,6 +580,7 @@ void recordDependencyExposures( SplitFeatureMetadata.featuresWithPrefix(metadata, EXPOSURE_FEATURE_PREFIX))); } } + return details == null ? Collections.emptyList() : Collections.unmodifiableList(details); } static final class DependencyEvaluationTrace { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java index b8cce08551a..f697e342acb 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java @@ -12,7 +12,13 @@ import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.ImmutableMetadata; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import org.slf4j.Logger; @@ -26,7 +32,8 @@ * OpenFeature hook can read it (see "Flag metadata" in the README). This class reads it for you. * *

Register it with {@code OpenFeatureAPI.getInstance().addHooks(new ExposureHook(callback))} or - * on a client. The callback runs for every evaluation, including errors and repeats. Use {@link + * on a client. The callback runs for every evaluation, including errors and repeats. A successful + * dependent-flag evaluation also invokes it for each selected prerequisite exposure. Use {@link * Evaluation#shouldSend()} to keep the new or changed exposures. * *

When the provider's own hooks run and {@code @@ -44,6 +51,7 @@ public final class ExposureHook implements Hook { private static final Logger log = LoggerFactory.getLogger(ExposureHook.class); + private static final ThreadLocal> ACTIVE_HOOKS = new ThreadLocal<>(); private final Consumer callback; private final AtomicBoolean warned = new AtomicBoolean(); @@ -58,16 +66,72 @@ public ExposureHook(final Consumer callback) { this.callback = requireNonNull(callback, "callback"); } + @Override + public Optional before( + final HookContext context, final Map hints) { + List hooks = ACTIVE_HOOKS.get(); + if (hooks == null) { + hooks = new ArrayList<>(); + ACTIVE_HOOKS.set(hooks); + } + hooks.add(this); + return Optional.empty(); + } + @Override public void finallyAfter( final HookContext context, final FlagEvaluationDetails details, final Map hints) { - if (details == null) { + try { + if (details != null) { + publish(new Evaluation(context.getCtx(), details)); + } + } finally { + unregister(); + } + } + + static void publishDependencies( + final HookContext context, final List> details) { + if (context == null || details == null || details.isEmpty()) { return; } + final List registered = ACTIVE_HOOKS.get(); + if (registered == null || registered.isEmpty()) { + return; + } + final Set published = + Collections.newSetFromMap(new IdentityHashMap()); + for (final ExposureHook exposureHook : registered) { + if (!published.add(exposureHook)) { + continue; + } + for (final FlagEvaluationDetails dependency : details) { + exposureHook.publish(new Evaluation(context.getCtx(), dependency)); + } + } + } + + private void unregister() { + final List hooks = ACTIVE_HOOKS.get(); + if (hooks == null) { + return; + } + for (int i = hooks.size() - 1; i >= 0; i--) { + if (hooks.get(i) == this) { + hooks.remove(i); + break; + } + } + if (hooks.isEmpty()) { + ACTIVE_HOOKS.remove(); + } + } + + private void publish(final Evaluation evaluation) { try { - this.callback.accept(new Evaluation(context.getCtx(), details)); + this.callback.accept(evaluation); } catch (final LinkageError | RuntimeException e) { if (this.warned.compareAndSet(false, true)) { log.warn("Exposure callback failed; later failures are logged at debug level", e); 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 b74208c2be9..8f516daac62 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -90,7 +90,9 @@ public void finallyAfter( final EvaluationContext context = ctx == null ? null : ctx.getCtx(); if (evaluator != null) { try { - evaluator.recordDependencyExposures(dependencyExposures, context); + final List> dependencyDetails = + evaluator.recordDependencyExposures(dependencyExposures, context); + ExposureHook.publishDependencies(ctx, dependencyDetails); } catch (final RuntimeException | LinkageError e) { // Dependency exposure delivery is best-effort and must not suppress the root exposure. warnOnce(e); 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 f8fd273d6c2..b1e67f0fad6 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 @@ -163,7 +163,14 @@ public EvaluationResult evaluateWithTrace( result = evaluateFlag(state, target, key, defaultValue); commitExposures = result.getErrorCode() == null; } catch (final DependencyGraphException e) { - result = error(defaultValue, e.errorCode, e.getMessage(), observeFullEvaluationData); + result = + error( + defaultValue, + e.configurationError ? e.errorCode : ErrorCode.GENERAL, + e.dependencyKey == null + ? e.getMessage() + : "prerequisite flag " + e.dependencyKey + ": " + e.getMessage(), + observeFullEvaluationData); commitExposures = false; } catch (final PatternSyntaxException e) { result = @@ -296,7 +303,10 @@ private static int maxDependencyDepth(final ServerConfiguration config) { } } throw new DependencyGraphException( - ErrorCode.PARSE_ERROR, "maxDependencyDepth must be an integer from 0 through 255"); + ErrorCode.PARSE_ERROR, + "maxDependencyDepth must be an integer from 0 through 255", + null, + true); } private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { @@ -830,7 +840,7 @@ private String resolveCurrent(final String flagKey) { try { maxDependencyDepth = FlagEvaluator.maxDependencyDepth(state.config); } catch (final DependencyGraphException e) { - throw emitFailure(flagKey, e.errorCode, e.getMessage()); + throw emitFailure(flagKey, e.errorCode, e.getMessage(), e.configurationError); } } final int depth = frames.isEmpty() ? 1 : frames.get(frames.size() - 1).depth + 1; @@ -875,7 +885,9 @@ private String resolve(final String flagKey, final int depth) { missing.getErrorCode(), missing.getErrorCode() == ErrorCode.FLAG_NOT_FOUND ? "prerequisite flag is missing" - : "invalid prerequisite flag configuration"); + : "invalid prerequisite flag configuration", + flagKey, + false); } active.add(flagKey); @@ -907,7 +919,7 @@ private String resolve(final String flagKey, final int depth) { final ProviderEvaluation failure = error(null, e.errorCode, e.getMessage(), state.observeFullEvaluationData); emitEvaluation(flagKey, failure); - throw e; + throw e.dependencyKey == null ? e.withDependencyKey(flagKey) : e; } catch (final PatternSyntaxException e) { throw emitFailure(flagKey, ErrorCode.PARSE_ERROR, e.getMessage()); } catch (final NumberFormatException e) { @@ -922,8 +934,17 @@ private String resolve(final String flagKey, final int depth) { private DependencyGraphException emitFailure( final String flagKey, final ErrorCode errorCode, final String message) { + return emitFailure(flagKey, errorCode, message, false); + } + + private DependencyGraphException emitFailure( + final String flagKey, + final ErrorCode errorCode, + final String message, + final boolean configurationError) { final DependencyGraphException failure = - new DependencyGraphException(errorCode, message == null ? errorCode.name() : message); + new DependencyGraphException( + errorCode, message == null ? errorCode.name() : message, flagKey, configurationError); emitEvaluation( flagKey, error(null, errorCode, failure.getMessage(), state.observeFullEvaluationData)); return failure; @@ -1071,21 +1092,42 @@ private MemoizedDependency( private static final class DependencyGraphException extends RuntimeException { private final ErrorCode errorCode; + private final String dependencyKey; + private final boolean configurationError; private DependencyGraphException(final ErrorCode errorCode, final String message) { + this(errorCode, message, null, false); + } + + private DependencyGraphException( + final ErrorCode errorCode, + final String message, + final String dependencyKey, + final boolean configurationError) { super(message); this.errorCode = errorCode; + this.dependencyKey = dependencyKey; + this.configurationError = configurationError; + } + + private DependencyGraphException withDependencyKey(final String dependencyKey) { + return new DependencyGraphException( + this.errorCode, getMessage(), dependencyKey, this.configurationError); } } public static final class DependencyExposure { public final String flagKey; + public final Object value; public final String variant; + public final String reason; public final ImmutableMetadata metadata; private DependencyExposure(final String flagKey, final ProviderEvaluation details) { this.flagKey = flagKey; + this.value = details.getValue(); this.variant = details.getVariant(); + this.reason = details.getReason(); this.metadata = details.getFlagMetadata(); } } 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 b2222633d98..b7310098a0a 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 @@ -162,10 +162,10 @@ private static ServerConfiguration toServerConfiguration(@Nullable final Object @Nullable private static EvaluatorParams toEvaluatorParams(@Nullable final Object value) { - if (value == null) { + if (!(value instanceof Map)) { return null; } - final Map json = asObject(value, "evaluatorParams"); + final Map json = (Map) value; return new EvaluatorParams( json.get("maxDependencyDepth"), json.containsKey("maxDependencyDepth")); } @@ -297,8 +297,8 @@ private static ConditionConfiguration toCondition(@Nullable final Object 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"); + if (!json.containsKey("key")) { + throw new IllegalArgumentException("flagEvaluation must contain key"); } final String key = asString(json.get("key"), "flagEvaluation.key"); if (key == null || key.isEmpty()) { 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 9b6373b0849..5e34da60d0d 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -526,6 +526,56 @@ void dependencyTelemetryHonorsEnabledProviderOptOut() throws Exception { assertEquals(1, optedOut.getValue()); assertTelemetryCounts(0); assertEquals(2, this.exposures.size(), "dependency and root exposures remain independent"); + assertEquals(2, this.customerEvaluations.size()); + assertTrue( + this.customerEvaluations.stream() + .anyMatch(evaluation -> "dependency".equals(evaluation.getDetails().getFlagKey()))); + assertTrue( + this.customerEvaluations.stream() + .anyMatch(evaluation -> "root".equals(evaluation.getDetails().getFlagKey()))); + assertTrue(this.customerEvaluations.stream().allMatch(ExposureHook.Evaluation::shouldSend)); + } + + @Test + void nestedDependentRootsPublishEachCustomerExposureOnce() 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) { + if ("outer".equals(context.getCtx().getTargetingKey())) { + assertEquals( + 1, + EvaluationTelemetryOptOutTest.this + .client + .getIntegerDetails( + "root", + 0, + EvaluationTelemetryOptOutTest.context("inner", new Value(false))) + .getValue()); + } + } + }); + + assertEquals( + 1, this.client.getIntegerDetails("root", 0, context("outer", new Value(false))).getValue()); + + assertEquals(4, this.customerEvaluations.size()); + assertEquals( + 2, + this.customerEvaluations.stream() + .filter(evaluation -> "dependency".equals(evaluation.getDetails().getFlagKey())) + .count()); + assertEquals( + 2, + this.customerEvaluations.stream() + .filter(evaluation -> "root".equals(evaluation.getDetails().getFlagKey())) + .count()); } @Test @@ -727,6 +777,7 @@ void parallelDependentRootsKeepIndependentReturnedBatches() throws Exception { assertEquals(4, this.events.size(), "dependency and root for each evaluation"); assertEquals(4, this.metrics.get()); assertEquals(4, this.exposures.size()); + assertEquals(4, this.customerEvaluations.size()); } @Test 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 bf369efb2b0..86f8dbb2b29 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java @@ -638,7 +638,7 @@ void dependencyExposureFailureDoesNotSuppressTheRootExposure() { final ExposureTestEvaluator failingDependencies = new ExposureTestEvaluator(configuration("on", Boolean.TRUE), true) { @Override - void recordDependencyExposures( + List> recordDependencyExposures( final List exposures, final EvaluationContext context) { throw new IllegalStateException("dependency queue failure"); } 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 index 2416b9548ab..51f51a2ab9e 100644 --- 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 @@ -107,7 +107,7 @@ void propagatesInvalidDependencyActivationUnderFutureExperimentOptOut() { final FlagEvaluator.EvaluationResult evaluation = evaluator.evaluateWithTrace(config(flags, null), String.class, "root", "default", context); - assertEquals(ErrorCode.PARSE_ERROR, evaluation.details.getErrorCode()); + assertEquals(ErrorCode.GENERAL, evaluation.details.getErrorCode()); assertEquals(singletonList("dependency:PARSE_ERROR"), evaluations(evaluation)); assertTrue(evaluation.dependencyExposures.isEmpty()); } @@ -433,21 +433,23 @@ void emitsMissingAndInvalidPrerequisiteErrors() { final FlagEvaluator.EvaluationResult missingEvaluation = evaluator() .evaluateWithTrace( - config(missingFlags, null), + config(missingFlags, null, true), String.class, "root", "default", new MutableContext("u")); - assertEquals(ErrorCode.FLAG_NOT_FOUND, missingEvaluation.details.getErrorCode()); + assertEquals(ErrorCode.GENERAL, missingEvaluation.details.getErrorCode()); + assertTrue(missingEvaluation.details.getErrorMessage().contains("missing")); assertEquals(singletonList("missing:FLAG_NOT_FOUND"), evaluations(missingEvaluation)); - final ServerConfiguration invalidConfig = config(missingFlags, null); + final ServerConfiguration invalidConfig = config(missingFlags, null, true); 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(ErrorCode.GENERAL, invalidEvaluation.details.getErrorCode()); + assertTrue(invalidEvaluation.details.getErrorMessage().contains("missing")); assertEquals(singletonList("missing:PARSE_ERROR"), evaluations(invalidEvaluation)); } @@ -509,7 +511,14 @@ private static List exposures(final FlagEvaluator.EvaluationResult re private static ServerConfiguration config( final Map flags, final EvaluatorParams params) { - return new ServerConfiguration("", "", false, null, flags, params); + return config(flags, params, false); + } + + private static ServerConfiguration config( + final Map flags, + final EvaluatorParams params, + final boolean observeFullEvaluationData) { + return new ServerConfiguration("", "", observeFullEvaluationData, null, flags, params); } private static Flag stringFlag( 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 34f785504ac..8d5fa116aa3 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -46,6 +46,43 @@ void parsesDependentFlagConditionsAndEvaluatorParams() throws Exception { assertEquals(ConditionOperator.ONE_OF, condition.operator); } + @Test + void ignoresUnknownFlagEvaluationFields() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"root\":{\"key\":\"root\",\"allocations\":[{\"key\":\"a\"," + + "\"rules\":[{\"conditions\":[{\"operator\":\"ONE_OF\"," + + "\"flagEvaluation\":{\"key\":\"dependency\",\"property\":\"variant\"}," + + "\"value\":[\"on\"]}]}],\"splits\":[]}]}}}"); + + assertEquals( + "dependency", + config + .flags + .get("root") + .allocations + .get(0) + .rules + .get(0) + .conditions + .get(0) + .flagEvaluationKey); + } + + @TableTest({ + "evaluator params", + "'0' ", + "'true' ", + "'\"future\"' ", + "'[]' " + }) + void ignoresNonObjectEvaluatorParams(final String evaluatorParams) throws Exception { + final ServerConfiguration config = + deserialize("{\"evaluatorParams\":" + evaluatorParams + ",\"flags\":{}}"); + + assertNull(config.evaluatorParams); + } + @TableTest({ "operator | values ", "ONE_OF | '[true, 1]' ", From d948973606303fab613565e8487a96eac0afe956 Mon Sep 17 00:00:00 2001 From: "tyler.potter" Date: Thu, 5 Nov 2026 22:56:03 +0000 Subject: [PATCH 09/12] fix(openfeature): scope exposure hook lifecycles Bind dependency publication to the exact evaluation and provider scope, and preserve lifecycle identity through the deprecated API delegate. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../com/datadog/openfeature/ExposureHook.java | 119 ++++++++-- .../openfeature/ExposureLoggingHook.java | 3 +- .../trace/api/openfeature/ExposureHook.java | 7 + .../EvaluationTelemetryOptOutTest.java | 206 +++++++++++++++++- .../datadog/openfeature/ExposureHookTest.java | 145 ++++++++++-- .../api/openfeature/ExposureHookTest.java | 15 +- 6 files changed, 448 insertions(+), 47 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java index f697e342acb..f8dae36dbcd 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java @@ -20,6 +20,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,10 +52,13 @@ public final class ExposureHook implements Hook { private static final Logger log = LoggerFactory.getLogger(ExposureHook.class); - private static final ThreadLocal> ACTIVE_HOOKS = new ThreadLocal<>(); + private static final AtomicLong LIFECYCLE_IDS = new AtomicLong(); + private static final ThreadLocal> ACTIVE_REGISTRATIONS = new ThreadLocal<>(); private final Consumer callback; private final AtomicBoolean warned = new AtomicBoolean(); + private final String lifecycleKey = + "datadog.exposureHookLifecycle." + LIFECYCLE_IDS.incrementAndGet(); /** * Creates an exposure hook. @@ -69,12 +73,17 @@ public ExposureHook(final Consumer callback) { @Override public Optional before( final HookContext context, final Map hints) { - List hooks = ACTIVE_HOOKS.get(); - if (hooks == null) { - hooks = new ArrayList<>(); - ACTIVE_HOOKS.set(hooks); + if (context == null || context.getHookData() == null) { + return Optional.empty(); } - hooks.add(this); + final Registration registration = new Registration(this, context); + context.getHookData().set(this.lifecycleKey, registration); + List registrations = ACTIVE_REGISTRATIONS.get(); + if (registrations == null) { + registrations = new ArrayList<>(); + ACTIVE_REGISTRATIONS.set(registrations); + } + registrations.add(registration); return Optional.empty(); } @@ -83,49 +92,111 @@ public void finallyAfter( final HookContext context, final FlagEvaluationDetails details, final Map hints) { + final Registration registration = registration(context); + if (registration == null) { + return; + } try { if (details != null) { publish(new Evaluation(context.getCtx(), details)); } } finally { - unregister(); + unregister(registration); } } - static void publishDependencies( - final HookContext context, final List> details) { - if (context == null || details == null || details.isEmpty()) { + static void bindProviderScope(final Object scope, final HookContext context) { + if (scope == null || context == null) { return; } - final List registered = ACTIVE_HOOKS.get(); - if (registered == null || registered.isEmpty()) { + final List registrations = ACTIVE_REGISTRATIONS.get(); + if (registrations == null || registrations.isEmpty()) { return; } - final Set published = + final Set bound = Collections.newSetFromMap(new IdentityHashMap()); - for (final ExposureHook exposureHook : registered) { - if (!published.add(exposureHook)) { + for (int i = registrations.size() - 1; i >= 0; i--) { + final Registration registration = registrations.get(i); + if (!bound.add(registration.hook)) { continue; } + if (registration.active + && registration.providerScope == null + && registration.matches(context)) { + registration.providerScope = scope; + } + } + } + + static void publishDependencies( + final Object scope, + final HookContext context, + final List> details) { + if (scope == null || context == null || details == null || details.isEmpty()) { + return; + } + final List registrations = ACTIVE_REGISTRATIONS.get(); + if (registrations == null || registrations.isEmpty()) { + return; + } + final List recipients = new ArrayList<>(); + for (final Registration registration : registrations) { + if (registration.active && registration.providerScope == scope) { + recipients.add(registration); + } + } + for (final Registration registration : recipients) { for (final FlagEvaluationDetails dependency : details) { - exposureHook.publish(new Evaluation(context.getCtx(), dependency)); + registration.hook.publish(new Evaluation(context.getCtx(), dependency)); } } } - private void unregister() { - final List hooks = ACTIVE_HOOKS.get(); - if (hooks == null) { + private Registration registration(final HookContext context) { + if (context == null || context.getHookData() == null) { + return null; + } + final Object value = context.getHookData().get(this.lifecycleKey); + if (!(value instanceof Registration)) { + return null; + } + final Registration registration = (Registration) value; + return registration.active && registration.hook == this ? registration : null; + } + + private static void unregister(final Registration registration) { + registration.active = false; + final List registrations = ACTIVE_REGISTRATIONS.get(); + if (registrations == null) { return; } - for (int i = hooks.size() - 1; i >= 0; i--) { - if (hooks.get(i) == this) { - hooks.remove(i); + for (int i = registrations.size() - 1; i >= 0; i--) { + if (registrations.get(i) == registration) { + registrations.remove(i); break; } } - if (hooks.isEmpty()) { - ACTIVE_HOOKS.remove(); + if (registrations.isEmpty()) { + ACTIVE_REGISTRATIONS.remove(); + } + } + + private static final class Registration { + private final ExposureHook hook; + private final String flagKey; + private final EvaluationContext evaluationContext; + private Object providerScope; + private boolean active = true; + + private Registration(final ExposureHook hook, final HookContext context) { + this.hook = hook; + this.flagKey = context.getFlagKey(); + this.evaluationContext = context.getCtx(); + } + + private boolean matches(final HookContext context) { + return java.util.Objects.equals(this.flagKey, context.getFlagKey()) + && this.evaluationContext == context.getCtx(); } } 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 8f516daac62..bbf4a191521 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -50,6 +50,7 @@ public Optional before( final DDEvaluator evaluator = this.evaluatorSupplier.get(); if (evaluator != null && ctx != null) { final Object scope = evaluator.beginDependencyExposureScope(ctx.getFlagKey(), ctx.getCtx()); + ExposureHook.bindProviderScope(scope, ctx); if (ctx.getHookData() != null) { ctx.getHookData().set(DDEvaluator.DEPENDENCY_TELEMETRY_SCOPE, scope); } @@ -92,7 +93,7 @@ public void finallyAfter( try { final List> dependencyDetails = evaluator.recordDependencyExposures(dependencyExposures, context); - ExposureHook.publishDependencies(ctx, dependencyDetails); + ExposureHook.publishDependencies(scope, ctx, dependencyDetails); } catch (final RuntimeException | LinkageError e) { // Dependency exposure delivery is best-effort and must not suppress the root exposure. warnOnce(e); diff --git a/dd-openfeature/src/main/java/datadog/trace/api/openfeature/ExposureHook.java b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/ExposureHook.java index 151b1ab3eea..fe9b87b4459 100644 --- a/dd-openfeature/src/main/java/datadog/trace/api/openfeature/ExposureHook.java +++ b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/ExposureHook.java @@ -7,6 +7,7 @@ import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; import java.util.Map; +import java.util.Optional; import java.util.function.Consumer; /** @@ -31,6 +32,12 @@ public ExposureHook(final Consumer callback) { evaluation -> callback.accept(new Evaluation(evaluation))); } + @Override + public Optional before( + final HookContext context, final Map hints) { + return this.delegate.before(context, hints); + } + @Override public void finallyAfter( final HookContext context, 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 5e34da60d0d..6831516ba3b 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -487,7 +487,9 @@ public void error( assertEquals(0, details.getValue()); assertTelemetryCounts(0); assertEquals(0, this.exposures.size()); - assertEquals(1, this.customerEvaluations.size()); + assertTrue( + this.customerEvaluations.isEmpty(), + "a skipped before hook has no lifecycle registration for finally"); } @Test @@ -578,6 +580,158 @@ public void after( .count()); } + @Test + void distinctNestedClientsReceiveOnlyTheirOwnDependencyExposures() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + dependentConfiguration()); + final List outerEvaluations = new ArrayList<>(); + final List innerEvaluations = new ArrayList<>(); + final Client outer = this.api.getClient("outer-client"); + final Client inner = this.api.getClient("inner-client"); + outer.addHooks( + new ExposureHook(outerEvaluations::add), + new Hook() { + @Override + public void after( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + if ("outer".equals(context.getCtx().getTargetingKey())) { + assertEquals( + 1, + inner + .getIntegerDetails( + "root", + 0, + EvaluationTelemetryOptOutTest.context("inner", new Value(false))) + .getValue()); + } + } + }); + inner.addHooks(new ExposureHook(innerEvaluations::add)); + + assertEquals( + 1, outer.getIntegerDetails("root", 0, context("outer", new Value(false))).getValue()); + + assertEquals(2, outerEvaluations.size()); + assertTrue( + outerEvaluations.stream() + .allMatch(evaluation -> "outer".equals(evaluation.getContext().getTargetingKey()))); + assertEquals(2, innerEvaluations.size()); + assertTrue( + innerEvaluations.stream() + .allMatch(evaluation -> "inner".equals(evaluation.getContext().getTargetingKey()))); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedExposureHookReceivesDependencyAndRootExactlyOnce() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + dependentConfiguration()); + final List deprecated = + new ArrayList<>(); + this.api.addHooks(new datadog.trace.api.openfeature.ExposureHook(deprecated::add)); + + assertEquals( + 1, + this.client + .getIntegerDetails("root", 0, context("deprecated", new Value(false))) + .getValue()); + + assertEquals(2, deprecated.size()); + assertEquals( + 1, + deprecated.stream() + .filter(evaluation -> "dependency".equals(evaluation.getDetails().getFlagKey())) + .count()); + assertEquals( + 1, + deprecated.stream() + .filter(evaluation -> "root".equals(evaluation.getDetails().getFlagKey())) + .count()); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedExposureHookDoesNotPublishDependenciesFromFailedOrRejectedGraphs() + throws Exception { + final List deprecated = + new ArrayList<>(); + this.api.addHooks(new datadog.trace.api.openfeature.ExposureHook(deprecated::add)); + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + failedDependencyConfiguration()); + + final FlagEvaluationDetails failed = + this.client.getIntegerDetails("root", 0, context("failed", new Value(false))); + + assertEquals(ErrorCode.GENERAL, failed.getErrorCode()); + assertEquals(1, deprecated.size()); + assertEquals("root", deprecated.get(0).getDetails().getFlagKey()); + + this.api.shutdown(); + deprecated.clear(); + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + rejectedDependencyConfiguration()); + this.api.addHooks(new datadog.trace.api.openfeature.ExposureHook(deprecated::add)); + + final FlagEvaluationDetails rejected = + this.client.getIntegerDetails("root", 0, context("rejected", new Value(false))); + + assertNull(rejected.getErrorCode()); + assertEquals(1, deprecated.size()); + assertEquals("root", deprecated.get(0).getDetails().getFlagKey()); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedExposureHookCleansUpNestedLifecycles() throws Exception { + start( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(false), + dependentConfiguration()); + final List deprecated = + new ArrayList<>(); + this.api.addHooks( + new datadog.trace.api.openfeature.ExposureHook(deprecated::add), + new Hook() { + @Override + public void after( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + if ("outer".equals(context.getCtx().getTargetingKey())) { + assertEquals( + 1, + EvaluationTelemetryOptOutTest.this + .client + .getIntegerDetails( + "root", + 0, + EvaluationTelemetryOptOutTest.context("inner", new Value(false))) + .getValue()); + } + } + }); + + assertEquals( + 1, this.client.getIntegerDetails("root", 0, context("outer", new Value(false))).getValue()); + + assertEquals(4, deprecated.size()); + assertEquals( + 2, + deprecated.stream() + .filter(evaluation -> "inner".equals(evaluation.getContext().getTargetingKey())) + .count()); + assertEquals( + 2, + deprecated.stream() + .filter(evaluation -> "outer".equals(evaluation.getContext().getTargetingKey())) + .count()); + } + @Test void enabledProviderOptOutIncludesDependencyTelemetryWhenSubjectDoesNotOptOut() throws Exception { start( @@ -1063,6 +1217,56 @@ private static ServerConfiguration dependentConfiguration() { return new ServerConfiguration("", "", false, null, flags); } + private static ServerConfiguration failedDependencyConfiguration() { + final Rule rule = + new Rule( + singletonList( + new ConditionConfiguration( + ConditionOperator.ONE_OF, null, "missing", singletonList("on")))); + final Allocation allocation = + 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(allocation)); + return new ServerConfiguration("", "", false, null, singletonMap("root", root)); + } + + private static ServerConfiguration rejectedDependencyConfiguration() { + final ServerConfiguration configuration = dependentConfiguration(); + final Flag root = configuration.flags.get("root"); + final ConditionConfiguration rejected = + new ConditionConfiguration( + ConditionOperator.ONE_OF, null, "dependency", singletonList("off")); + final Allocation allocation = + new Allocation( + "rejected", + singletonList(new Rule(singletonList(rejected))), + null, + null, + root.allocations.get(0).splits, + true); + final Map flags = new HashMap<>(configuration.flags); + flags.put( + "root", + new Flag( + root.key, + root.enabled, + root.variationType, + root.variations, + singletonList(allocation))); + return new ServerConfiguration("", "", false, null, flags); + } + private static ServerConfiguration twoDependencyConfiguration( final boolean observeFullEvaluationData) { final Map flags = new HashMap<>(); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureHookTest.java index 81aae5bbbda..c3a20095f6d 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureHookTest.java @@ -34,6 +34,7 @@ import com.datadog.openfeature.internal.ufc.ValueType; import com.datadog.openfeature.internal.ufc.Variant; import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.DefaultHookData; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; @@ -46,6 +47,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -312,21 +314,19 @@ void theHookContainsCallbackFailures() { final HookContext context = hookContext(); final FlagEvaluationDetails details = FlagEvaluationDetails.builder().flagKey("flag").value(1).build(); + final ExposureHook runtimeFailure = + new ExposureHook( + evaluation -> { + throw new IllegalStateException("warehouse unavailable"); + }); + final ExposureHook linkageFailure = + new ExposureHook( + evaluation -> { + throw new NoClassDefFoundError("missing class"); + }); - assertDoesNotThrow( - () -> - new ExposureHook( - evaluation -> { - throw new IllegalStateException("warehouse unavailable"); - }) - .finallyAfter(context, details, emptyMap())); - assertDoesNotThrow( - () -> - new ExposureHook( - evaluation -> { - throw new NoClassDefFoundError("missing class"); - }) - .finallyAfter(context, details, emptyMap())); + assertDoesNotThrow(() -> runLifecycle(runtimeFailure, context, details)); + assertDoesNotThrow(() -> runLifecycle(linkageFailure, hookContext(), details)); } @Test @@ -339,9 +339,9 @@ void keepsContainingCallbackFailuresAfterTheFirstOne() { final FlagEvaluationDetails details = FlagEvaluationDetails.builder().flagKey("flag").value(1).build(); - hook.finallyAfter(hookContext(), details, emptyMap()); + runLifecycle(hook, hookContext(), details); - assertDoesNotThrow(() -> hook.finallyAfter(hookContext(), details, emptyMap())); + assertDoesNotThrow(() -> runLifecycle(hook, hookContext(), details)); } @Test @@ -354,9 +354,87 @@ void requiresACallback() { @Test void skipsTheCallbackWhenThereAreNoDetails() { final List seen = new ArrayList<>(); + final ExposureHook hook = new ExposureHook(seen::add); + + runLifecycle(hook, hookContext(), null); + + assertTrue(seen.isEmpty()); + } + + @Test + void unmatchedFinallyIsANoOp() { + final List seen = new ArrayList<>(); + final ExposureHook hook = new ExposureHook(seen::add); + final HookContext active = hookContext("active"); + final Object scope = new Object(); + hook.before(active, emptyMap()); + ExposureHook.bindProviderScope(scope, active); + + hook.finallyAfter( + hookContext("unmatched"), + FlagEvaluationDetails.builder().flagKey("flag").value(1).build(), + emptyMap()); + + assertTrue(seen.isEmpty()); + ExposureHook.publishDependencies(scope, active, singletonList(dependencyDetails())); + hook.finallyAfter( + active, + FlagEvaluationDetails.builder().flagKey("flag").value(1).build(), + emptyMap()); + assertEquals(2, seen.size()); + } + + @Test + void sameHookNestingKeepsProviderScopesExact() { + final List seen = new ArrayList<>(); + final ExposureHook hook = new ExposureHook(seen::add); + final HookContext outer = hookContext("outer"); + final HookContext inner = hookContext("inner"); + final Object outerScope = new Object(); + final Object innerScope = new Object(); + + hook.before(outer, emptyMap()); + ExposureHook.bindProviderScope(outerScope, outer); + hook.before(inner, emptyMap()); + ExposureHook.bindProviderScope(innerScope, inner); + ExposureHook.publishDependencies(innerScope, inner, singletonList(dependencyDetails())); + hook.finallyAfter(inner, rootDetails(), emptyMap()); + ExposureHook.publishDependencies(outerScope, outer, singletonList(dependencyDetails())); + hook.finallyAfter(outer, rootDetails(), emptyMap()); + + assertEquals(4, seen.size()); + assertEquals("inner", seen.get(0).getContext().getTargetingKey()); + assertEquals("inner", seen.get(1).getContext().getTargetingKey()); + assertEquals("outer", seen.get(2).getContext().getTargetingKey()); + assertEquals("outer", seen.get(3).getContext().getTargetingKey()); + } - new ExposureHook(seen::add).finallyAfter(hookContext(), null, emptyMap()); + @Test + void callbackExceptionsAndNullDetailsCannotLeaveStaleRegistrations() { + final AtomicInteger attempts = new AtomicInteger(); + final ExposureHook failing = + new ExposureHook( + evaluation -> { + attempts.incrementAndGet(); + throw new IllegalStateException("warehouse unavailable"); + }); + final HookContext failed = hookContext("failed"); + final Object failedScope = new Object(); + failing.before(failed, emptyMap()); + ExposureHook.bindProviderScope(failedScope, failed); + ExposureHook.publishDependencies(failedScope, failed, singletonList(dependencyDetails())); + failing.finallyAfter(failed, rootDetails(), emptyMap()); + ExposureHook.publishDependencies(failedScope, failed, singletonList(dependencyDetails())); + assertEquals(2, attempts.get()); + final List seen = new ArrayList<>(); + final ExposureHook cleaned = new ExposureHook(seen::add); + final HookContext empty = hookContext("empty"); + final Object emptyScope = new Object(); + cleaned.before(empty, emptyMap()); + ExposureHook.bindProviderScope(emptyScope, empty); + cleaned.finallyAfter(empty, null, emptyMap()); + ExposureHook.publishDependencies(emptyScope, empty, singletonList(dependencyDetails())); assertTrue(seen.isEmpty()); } @@ -370,14 +448,45 @@ private void start(final boolean sendToDatadog, final ServerConfiguration config } private static HookContext hookContext() { + return hookContext("user-1"); + } + + private static HookContext hookContext(final String targetingKey) { return HookContext.builder() .flagKey("flag") .type(FlagValueType.INTEGER) .defaultValue(0) - .ctx(new MutableContext("user-1")) + .ctx(new MutableContext(targetingKey)) + .hookData(new DefaultHookData()) + .build(); + } + + private static FlagEvaluationDetails dependencyDetails() { + return FlagEvaluationDetails.builder() + .flagKey("dependency") + .value(1) + .variant("on") + .flagMetadata(exposureMetadata("dependency-allocation", true, false)) .build(); } + private static FlagEvaluationDetails rootDetails() { + return FlagEvaluationDetails.builder() + .flagKey("root") + .value(1) + .variant("on") + .flagMetadata(exposureMetadata("root-allocation", true, false)) + .build(); + } + + private static void runLifecycle( + final ExposureHook hook, + final HookContext context, + final FlagEvaluationDetails details) { + hook.before(context, emptyMap()); + hook.finallyAfter(context, details, emptyMap()); + } + private static ExposureHook.Evaluation evaluation( final String variant, final ImmutableMetadata metadata) { final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = diff --git a/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java index ace0ac5c052..b386a50f957 100644 --- a/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java +++ b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import dev.openfeature.sdk.DefaultHookData; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; import dev.openfeature.sdk.HookContext; @@ -34,6 +35,7 @@ void aliasReportsTheExposureDecision() { .addString("__dd_feature.holdout.key", "holdout") .build()); + hook.before(context, emptyMap()); hook.finallyAfter(context, details, emptyMap()); assertEquals(1, evaluations.size()); @@ -51,7 +53,9 @@ void aliasReportsANonExposure() { final List evaluations = new ArrayList<>(); final ExposureHook hook = new ExposureHook(evaluations::add); - hook.finallyAfter(context(), details(ImmutableMetadata.builder().build()), emptyMap()); + final HookContext context = context(); + hook.before(context, emptyMap()); + hook.finallyAfter(context, details(ImmutableMetadata.builder().build()), emptyMap()); final ExposureHook.Evaluation evaluation = evaluations.get(0); assertFalse(evaluation.isExposure()); @@ -66,8 +70,13 @@ void aliasRejectsANullCallback() { } private static HookContext context() { - return HookContext.from( - "flag", FlagValueType.STRING, null, null, new ImmutableContext("user"), "default"); + return HookContext.builder() + .flagKey("flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(new ImmutableContext("user")) + .hookData(new DefaultHookData()) + .build(); } private static FlagEvaluationDetails details(final ImmutableMetadata metadata) { From 1a53e976327fb34635c9c52f14627f8af2d15c7d Mon Sep 17 00:00:00 2001 From: "tyler.potter" Date: Thu, 5 Nov 2026 15:46:51 +0000 Subject: [PATCH 10/12] Test dependent flags with shared FFE corpus --- .../openfeature/CanonicalFixtureTest.java | 366 +++++++++++++++--- .../src/test/resources/ffe-system-test-data | 2 +- 2 files changed, 323 insertions(+), 45 deletions(-) diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java index c89e1e4ab0d..24816ce3da4 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -7,15 +7,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import com.datadog.openfeature.internal.JsonReading; import com.datadog.openfeature.internal.bridge.v1.AgentBridge; import com.datadog.openfeature.internal.config.TestSettings; import com.datadog.openfeature.internal.evaluation.ExperimentationOptOut; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import dev.openfeature.sdk.DefaultHookData; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; @@ -24,27 +32,43 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; -/** - * Evaluates the cross-SDK canonical fixtures from the {@code ffe-system-test-data} submodule, - * parsed with the production UFC parser. - */ +/** Runs the shared FFE evaluation suites through the production UFC parser and evaluator. */ class CanonicalFixtureTest { - private static final String CANONICAL_FIXTURE_PATH = + private static final String PLATFORM = "java"; + private static final String FIXTURE_PATH = "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; + private static final FixtureSuite CORE = + new FixtureSuite("core", "ufc-config.json", "evaluation-cases"); + private static final FixtureSuite DEPENDENT_FLAGS = + new FixtureSuite( + "dependent-flags", + "evaluation-suites/dependent-flags/ufc-config.json", + "evaluation-suites/dependent-flags/cases"); + private static final List SUITES = Arrays.asList(CORE, DEPENDENT_FLAGS); @Test void canonicalFixturesArePresent() throws IOException { assertThat(canonicalTestCases().size(), greaterThan(0)); } + @Test + void dependentFlagFixturesArePresent() throws IOException { + assertThat(testCases(DEPENDENT_FLAGS).size(), greaterThan(0)); + } + @MethodSource("canonicalTestCases") @ParameterizedTest(name = "{0}") void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { @@ -75,13 +99,13 @@ static List immediateOptOutTestCases() throws IOException { private static List optOutTestCases(final String capability, final int expectedCount) throws IOException { final List cases = new ArrayList<>(); - for (final FixtureCase testCase : canonicalTestCases()) { + for (final FixtureCase testCase : testCases(CORE)) { final Map capabilities = (Map) testCase.json.get("capabilities"); if (capabilities != null && capabilities.containsKey(capability)) { - final Map json = new java.util.LinkedHashMap<>(testCase.json); + final Map json = new LinkedHashMap<>(testCase.json); json.put("result", capabilities.get(capability)); - cases.add(new FixtureCase(testCase.fileName, testCase.index, json)); + cases.add(new FixtureCase(testCase.suite, testCase.fileName, testCase.index, json)); } } assertEquals(expectedCount, cases.size(), "The opt-out capability fixtures must execute"); @@ -90,26 +114,48 @@ private static List optOutTestCases(final String capability, final private void evaluateCanonicalFixture( final FixtureCase testCase, final ExperimentationOptOutMode mode) throws IOException { - final DDEvaluator evaluator = - new DDEvaluator( - mock(Runnable.class), - AgentBridge.NONE, - TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true"), - mode); - evaluator.accept( - UniversalFlagConfigParser.parse( - Files.readAllBytes(fixtureRoot().resolve("ufc-config.json")))); - final Class targetType = targetType(testCase.variationType); final Object defaultValue = fixtureValue(targetType, testCase.defaultValue); final Object expectedValue = fixtureValue(targetType, testCase.resultValue); - final Provider provider = - new Provider(new Provider.Options().experimentationOptOutMode(mode), evaluator, false); final ProviderEvaluation details; - try { - details = evaluate(provider, targetType, testCase.flag, defaultValue, context(testCase)); - } finally { - provider.shutdown(); + final List exposures; + final List evaluationEvents; + + if (testCase.expectations != null && mode == ExperimentationOptOutMode.DISABLED) { + final ExposureTestEvaluator evaluator = + new ExposureTestEvaluator( + UniversalFlagConfigParser.parse( + Files.readAllBytes(fixtureRoot().resolve(testCase.suite.ufcPath))), + true); + evaluationEvents = new ArrayList<>(); + details = + evaluateWithTelemetry( + evaluator, + new CapturingEvaluationWriter(evaluationEvents), + targetType, + testCase.flag, + defaultValue, + context(testCase)); + exposures = evaluator.sentToDatadog; + } else { + final DDEvaluator evaluator = + new DDEvaluator( + mock(Runnable.class), + AgentBridge.NONE, + TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true"), + mode); + evaluator.accept( + UniversalFlagConfigParser.parse( + Files.readAllBytes(fixtureRoot().resolve(testCase.suite.ufcPath)))); + final Provider provider = + new Provider(new Provider.Options().experimentationOptOutMode(mode), evaluator, false); + try { + details = evaluate(provider, targetType, testCase.flag, defaultValue, context(testCase)); + } finally { + provider.shutdown(); + } + exposures = Collections.emptyList(); + evaluationEvents = Collections.emptyList(); } assertThat(details.getValue(), equalTo(expectedValue)); @@ -141,6 +187,9 @@ private void evaluateCanonicalFixture( details.getFlagMetadata().getString("allocationKey"), equalTo(testCase.resultAllocationKey)); } + if (testCase.expectations != null) { + testCase.expectations.assertMatches(exposures, evaluationEvents); + } } private static Object fixtureValue(final Class type, final Object value) { @@ -180,37 +229,112 @@ private static ProviderEvaluation evaluate( return provider.getObjectEvaluation(flag, (Value) defaultValue, context); } - @SuppressWarnings("unchecked") + @SuppressWarnings({"rawtypes", "unchecked"}) + private static ProviderEvaluation evaluateWithTelemetry( + final ExposureTestEvaluator evaluator, + final FlagEvaluationWriter writer, + final Class targetType, + final String flag, + final Object defaultValue, + final EvaluationContext context) { + final HookContext hookContext = + HookContext.builder() + .flagKey(flag) + .type(flagValueType(targetType)) + .defaultValue(defaultValue) + .ctx(context) + .hookData(new DefaultHookData()) + .build(); + final Map hints = new HashMap<>(); + final ExposureLoggingHook exposureHook = new ExposureLoggingHook(() -> evaluator); + final FlagEvalLoggingHook evaluationHook = + new FlagEvalLoggingHook(() -> writer, () -> evaluator); + + exposureHook.before(hookContext, hints); + evaluationHook.before(hookContext, hints); + final ProviderEvaluation evaluation = + evaluator.evaluate((Class) targetType, flag, defaultValue, context); + final FlagEvaluationDetails details = FlagEvaluationDetails.from(evaluation, flag); + evaluationHook.finallyAfter(hookContext, details, hints); + exposureHook.finallyAfter(hookContext, details, hints); + return evaluation; + } + + private static FlagValueType flagValueType(final Class targetType) { + if (Boolean.class.equals(targetType)) { + return FlagValueType.BOOLEAN; + } + if (Integer.class.equals(targetType)) { + return FlagValueType.INTEGER; + } + if (Double.class.equals(targetType)) { + return FlagValueType.DOUBLE; + } + if (String.class.equals(targetType)) { + return FlagValueType.STRING; + } + return FlagValueType.OBJECT; + } + static List canonicalTestCases() throws IOException { - final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); final List result = new ArrayList<>(); - try (Stream paths = Files.list(evaluationCases)) { + for (final FixtureSuite suite : SUITES) { + result.addAll(testCases(suite)); + } + assertThat(result.size(), greaterThan(0)); + return result; + } + + @SuppressWarnings("unchecked") + private static List testCases(final FixtureSuite suite) throws IOException { + final List result = new ArrayList<>(); + try (Stream paths = Files.list(fixtureRoot().resolve(suite.caseDirectory))) { final List files = paths .filter(path -> path.getFileName().toString().endsWith(".json")) .sorted() .collect(Collectors.toList()); for (final Path file : files) { - final List testCases = (List) JsonReading.read(Files.readAllBytes(file)); - for (int index = 0; index < testCases.size(); index++) { - result.add( - new FixtureCase( - file.getFileName().toString(), - index, - (Map) testCases.get(index))); + final List cases = (List) JsonReading.read(Files.readAllBytes(file)); + for (int index = 0; index < cases.size(); index++) { + final Map testCase = (Map) cases.get(index); + if (isApplicable(testCase)) { + result.add(new FixtureCase(suite, file.getFileName().toString(), index, testCase)); + } } } } - assertThat(result.size(), greaterThan(0)); return result; } + private static boolean isApplicable(final Map item) { + if (selectsPlatform(item.get("_skip"))) { + return false; + } + return !item.containsKey("_include") || selectsPlatform(item.get("_include")); + } + + private static boolean selectsPlatform(final Object selectors) { + if (!(selectors instanceof List)) { + return false; + } + for (final Object selector : (List) selectors) { + if (selector instanceof Map + && PLATFORM.equals(String.valueOf(((Map) selector).get("platform")))) { + return true; + } + } + return false; + } + private static Path fixtureRoot() { Path directory = Paths.get("").toAbsolutePath(); while (directory != null) { - final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); - if (Files.exists(candidate.resolve("ufc-config.json")) - && Files.isDirectory(candidate.resolve("evaluation-cases"))) { + final Path candidate = directory.resolve(FIXTURE_PATH); + if (Files.exists(candidate.resolve(CORE.ufcPath)) + && Files.isDirectory(candidate.resolve(CORE.caseDirectory)) + && Files.exists(candidate.resolve(DEPENDENT_FLAGS.ufcPath)) + && Files.isDirectory(candidate.resolve(DEPENDENT_FLAGS.caseDirectory))) { return candidate; } directory = directory.getParent(); @@ -244,26 +368,63 @@ private static Class targetType(final String variationType) { } } - /** Normalizes the integral numbers read as Long to Double, as the UFC parser does. */ + /** Normalizes integral numbers read as Long to Double, as the UFC parser does. */ private static Object normalize(final Object value) { if (value instanceof Long) { return ((Long) value).doubleValue(); } if (value instanceof Map) { - final Map map = new java.util.LinkedHashMap<>(); - ((Map) value).forEach((k, v) -> map.put((String) k, normalize(v))); + final Map map = new LinkedHashMap<>(); + ((Map) value).forEach((key, nested) -> map.put((String) key, normalize(nested))); return map; } if (value instanceof List) { final List list = new ArrayList<>(); - ((List) value).forEach(v -> list.add(normalize(v))); + ((List) value).forEach(nested -> list.add(normalize(nested))); return list; } return value; } + private static final class CapturingEvaluationWriter implements FlagEvaluationWriter { + private final List events; + + private CapturingEvaluationWriter(final List events) { + this.events = events; + } + + @Override + public boolean hasCapacityForEnqueue() { + return true; + } + + @Override + public void countPreQueueOverflow() {} + + @Override + public void countContextTruncated(final String reason) {} + + @Override + public void enqueue(final FlagEvalEvent event) { + events.add(event); + } + } + + private static final class FixtureSuite { + final String name; + final String ufcPath; + final String caseDirectory; + + private FixtureSuite(final String name, final String ufcPath, final String caseDirectory) { + this.name = name; + this.ufcPath = ufcPath; + this.caseDirectory = caseDirectory; + } + } + static final class FixtureCase { final Map json; + final FixtureSuite suite; final String fileName; final int index; final Map attributes; @@ -279,10 +440,16 @@ static final class FixtureCase { final boolean checksSerialId; final Number resultSerialId; final Boolean resultDoLog; + final FixtureExpectations expectations; @SuppressWarnings("unchecked") - FixtureCase(final String fileName, final int index, final Map json) { + FixtureCase( + final FixtureSuite suite, + final String fileName, + final int index, + final Map json) { this.json = json; + this.suite = suite; this.fileName = fileName; this.index = index; final Object attributes = normalize(json.get("attributes")); @@ -302,11 +469,122 @@ static final class FixtureCase { final Map metadata = (Map) result.get("flagMetadata"); final Object allocationKey = metadata == null ? null : metadata.get("allocationKey"); this.resultAllocationKey = allocationKey == null ? null : String.valueOf(allocationKey); + final Map expectations = (Map) json.get("expectations"); + this.expectations = expectations == null ? null : new FixtureExpectations(expectations); + } + + @Override + public String toString() { + return suite.name + "/" + fileName + "[" + index + "] flag=" + flag; + } + } + + private static final class FixtureExpectations { + final List exposures; + final List evaluationEvents; + final boolean noUnmatchedEvents; + + @SuppressWarnings("unchecked") + private FixtureExpectations(final Map json) { + this.exposures = matchers((List) json.get("exposures")); + this.evaluationEvents = matchers((List) json.get("evaluationEvents")); + this.noUnmatchedEvents = Boolean.TRUE.equals(json.get("noUnmatchedEvents")); + } + + private void assertMatches( + final List actualExposures, + final List actualEvaluationEvents) { + assertEvents(exposures, actualExposures, noUnmatchedEvents, "exposure"); + assertEvents(evaluationEvents, actualEvaluationEvents, noUnmatchedEvents, "evaluation"); + } + + private static void assertEvents( + final List expected, + final List actual, + final boolean exact, + final String eventType) { + int actualIndex = 0; + for (final EventMatcher matcher : expected) { + for (int count = 0; count < matcher.count; count++) { + assertTrue(actualIndex < actual.size(), "missing " + eventType + " for " + matcher); + assertTrue( + matcher.matches(actual.get(actualIndex)), + eventType + " at index " + actualIndex + " did not match " + matcher); + actualIndex++; + } + } + if (exact) { + assertEquals(actualIndex, actual.size(), "unexpected " + eventType + " events"); + } + } + + @SuppressWarnings("unchecked") + private static List matchers(final List items) { + if (items == null) { + return Collections.emptyList(); + } + final List result = new ArrayList<>(); + for (final Object item : items) { + final Map matcher = (Map) item; + if (isApplicable(matcher)) { + result.add(new EventMatcher(matcher)); + } + } + return result; + } + } + + private static final class EventMatcher { + final int count; + final Map fields; + + private EventMatcher(final Map json) { + final Object count = json.get("_count"); + this.count = count instanceof Number ? ((Number) count).intValue() : 1; + this.fields = new LinkedHashMap<>(json); + this.fields.remove("_count"); + this.fields.remove("_skip"); + this.fields.remove("_include"); + } + + private boolean matches(final Object event) { + for (final Map.Entry field : fields.entrySet()) { + final Object actual = actualField(event, field.getKey()); + if (!Objects.equals(field.getValue(), actual)) { + return false; + } + } + return true; + } + + private static Object actualField(final Object event, final String field) { + if (event instanceof ExposureEvent) { + final ExposureEvent exposure = (ExposureEvent) event; + switch (field) { + case "flag": + return exposure.flag == null ? null : exposure.flag.key; + case "allocation": + return exposure.allocation == null ? null : exposure.allocation.key; + case "variant": + return exposure.variant == null ? null : exposure.variant.key; + default: + throw new IllegalArgumentException("Unsupported exposure field: " + field); + } + } + final FlagEvalEvent evaluation = (FlagEvalEvent) event; + switch (field) { + case "flag": + return evaluation.flagKey; + case "errorCode": + return evaluation.errorMessage; + default: + throw new IllegalArgumentException("Unsupported evaluation field: " + field); + } } @Override public String toString() { - return this.fileName + "[" + this.index + "] flag=" + this.flag; + return fields + " x" + count; } } } diff --git a/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data b/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data index 8c4d2410aa6..9afc3f75c3f 160000 --- a/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data +++ b/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data @@ -1 +1 @@ -Subproject commit 8c4d2410aa643aa6b97c8547ae5fe358c46b7f6f +Subproject commit 9afc3f75c3fef9643a9c1b5e2b1e5a9adac48177 From 0ad68108fecafc88cfaf3e080c586bbee7f2ff34 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 5 Nov 2026 11:09:11 -0600 Subject: [PATCH 11/12] Test corpus through the OpenFeature client Exercise production provider hooks and greedily match emitted telemetry. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../openfeature/CanonicalFixtureTest.java | 252 ++++++++++-------- 1 file changed, 138 insertions(+), 114 deletions(-) diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java index 24816ce3da4..1dde391118b 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -1,44 +1,52 @@ package com.datadog.openfeature; import static java.util.Collections.emptyMap; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThan; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import com.datadog.openfeature.internal.ConfigurationService; +import com.datadog.openfeature.internal.FeatureFlagsRuntime; import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.RuntimeServices; import com.datadog.openfeature.internal.bridge.v1.AgentBridge; +import com.datadog.openfeature.internal.bridge.v1.HealthMetrics; import com.datadog.openfeature.internal.config.TestSettings; import com.datadog.openfeature.internal.evaluation.ExperimentationOptOut; +import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.exposure.ExposurePipeline; import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; -import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; -import dev.openfeature.sdk.DefaultHookData; +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.HookContext; import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.Value; import java.io.IOException; +import java.lang.reflect.Constructor; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -69,21 +77,42 @@ void dependentFlagFixturesArePresent() throws IOException { assertThat(testCases(DEPENDENT_FLAGS).size(), greaterThan(0)); } + @Test + void eventMatchersGreedilyConsumeMatchingEvents() { + final Map matcher = new LinkedHashMap<>(); + matcher.put("flag", "expected"); + final Map expectation = new LinkedHashMap<>(); + expectation.put("evaluationEvents", Collections.singletonList(matcher)); + final FixtureExpectations expectations = new FixtureExpectations(expectation); + final List actual = + Arrays.asList( + new FlagEvalEvent("unmatched", null, null, null, 0, emptyMap()), + new FlagEvalEvent("expected", null, null, null, 0, emptyMap())); + + expectations.assertMatches(Collections.emptyList(), actual); + + expectation.put("noUnmatchedEvents", true); + final FixtureExpectations exactExpectations = new FixtureExpectations(expectation); + assertThrows( + AssertionError.class, + () -> exactExpectations.assertMatches(Collections.emptyList(), actual)); + } + @MethodSource("canonicalTestCases") @ParameterizedTest(name = "{0}") - void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { + void evaluateCanonicalFixture(final FixtureCase testCase) throws Exception { evaluateCanonicalFixture(testCase, ExperimentationOptOutMode.DISABLED); } @MethodSource("immediateOptOutTestCases") @ParameterizedTest(name = "immediate: {0}") - void evaluateImmediateOptOutFixture(final FixtureCase testCase) throws IOException { + void evaluateImmediateOptOutFixture(final FixtureCase testCase) throws Exception { evaluateCanonicalFixture(testCase, ExperimentationOptOutMode.IMMEDIATE); } @MethodSource("futureOptOutTestCases") @ParameterizedTest(name = "future: {0}") - void evaluateFutureOptOutFixture(final FixtureCase testCase) throws IOException { + void evaluateFutureOptOutFixture(final FixtureCase testCase) throws Exception { evaluateCanonicalFixture(testCase, ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY); } @@ -113,49 +142,32 @@ private static List optOutTestCases(final String capability, final } private void evaluateCanonicalFixture( - final FixtureCase testCase, final ExperimentationOptOutMode mode) throws IOException { + final FixtureCase testCase, final ExperimentationOptOutMode mode) throws Exception { final Class targetType = targetType(testCase.variationType); final Object defaultValue = fixtureValue(targetType, testCase.defaultValue); final Object expectedValue = fixtureValue(targetType, testCase.resultValue); - final ProviderEvaluation details; - final List exposures; - final List evaluationEvents; - - if (testCase.expectations != null && mode == ExperimentationOptOutMode.DISABLED) { - final ExposureTestEvaluator evaluator = - new ExposureTestEvaluator( - UniversalFlagConfigParser.parse( - Files.readAllBytes(fixtureRoot().resolve(testCase.suite.ufcPath))), - true); - evaluationEvents = new ArrayList<>(); + final List evaluationEvents = new ArrayList<>(); + final boolean captureTelemetry = + testCase.expectations != null && mode == ExperimentationOptOutMode.DISABLED; + final FeatureFlagsRuntime runtime = + captureTelemetry ? capturingRuntime(new CapturingPipeline(evaluationEvents)) : null; + final FixtureEvaluator evaluator = + new FixtureEvaluator( + UniversalFlagConfigParser.parse( + Files.readAllBytes(fixtureRoot().resolve(testCase.suite.ufcPath))), + mode, + runtime, + captureTelemetry); + final Provider provider = + new Provider(new Provider.Options().experimentationOptOutMode(mode), evaluator, false); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final FlagEvaluationDetails details; + try { + api.setProviderAndWait(provider); details = - evaluateWithTelemetry( - evaluator, - new CapturingEvaluationWriter(evaluationEvents), - targetType, - testCase.flag, - defaultValue, - context(testCase)); - exposures = evaluator.sentToDatadog; - } else { - final DDEvaluator evaluator = - new DDEvaluator( - mock(Runnable.class), - AgentBridge.NONE, - TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true"), - mode); - evaluator.accept( - UniversalFlagConfigParser.parse( - Files.readAllBytes(fixtureRoot().resolve(testCase.suite.ufcPath)))); - final Provider provider = - new Provider(new Provider.Options().experimentationOptOutMode(mode), evaluator, false); - try { - details = evaluate(provider, targetType, testCase.flag, defaultValue, context(testCase)); - } finally { - provider.shutdown(); - } - exposures = Collections.emptyList(); - evaluationEvents = Collections.emptyList(); + evaluate(api.getClient(), targetType, testCase.flag, defaultValue, context(testCase)); + } finally { + api.shutdown(); } assertThat(details.getValue(), equalTo(expectedValue)); @@ -188,7 +200,7 @@ private void evaluateCanonicalFixture( equalTo(testCase.resultAllocationKey)); } if (testCase.expectations != null) { - testCase.expectations.assertMatches(exposures, evaluationEvents); + testCase.expectations.assertMatches(evaluator.exposures, evaluationEvents); } } @@ -208,72 +220,35 @@ private static Object fixtureValue(final Class type, final Object value) { return value; } - private static ProviderEvaluation evaluate( - final Provider provider, + private static FlagEvaluationDetails evaluate( + final Client client, final Class targetType, final String flag, final Object defaultValue, final EvaluationContext context) { if (targetType == Boolean.class) { - return provider.getBooleanEvaluation(flag, (Boolean) defaultValue, context); + return client.getBooleanDetails(flag, (Boolean) defaultValue, context); } if (targetType == Integer.class) { - return provider.getIntegerEvaluation(flag, (Integer) defaultValue, context); + return client.getIntegerDetails(flag, (Integer) defaultValue, context); } if (targetType == Double.class) { - return provider.getDoubleEvaluation(flag, (Double) defaultValue, context); + return client.getDoubleDetails(flag, (Double) defaultValue, context); } if (targetType == String.class) { - return provider.getStringEvaluation(flag, (String) defaultValue, context); + return client.getStringDetails(flag, (String) defaultValue, context); } - return provider.getObjectEvaluation(flag, (Value) defaultValue, context); + return client.getObjectDetails(flag, (Value) defaultValue, context); } - @SuppressWarnings({"rawtypes", "unchecked"}) - private static ProviderEvaluation evaluateWithTelemetry( - final ExposureTestEvaluator evaluator, - final FlagEvaluationWriter writer, - final Class targetType, - final String flag, - final Object defaultValue, - final EvaluationContext context) { - final HookContext hookContext = - HookContext.builder() - .flagKey(flag) - .type(flagValueType(targetType)) - .defaultValue(defaultValue) - .ctx(context) - .hookData(new DefaultHookData()) - .build(); - final Map hints = new HashMap<>(); - final ExposureLoggingHook exposureHook = new ExposureLoggingHook(() -> evaluator); - final FlagEvalLoggingHook evaluationHook = - new FlagEvalLoggingHook(() -> writer, () -> evaluator); - - exposureHook.before(hookContext, hints); - evaluationHook.before(hookContext, hints); - final ProviderEvaluation evaluation = - evaluator.evaluate((Class) targetType, flag, defaultValue, context); - final FlagEvaluationDetails details = FlagEvaluationDetails.from(evaluation, flag); - evaluationHook.finallyAfter(hookContext, details, hints); - exposureHook.finallyAfter(hookContext, details, hints); - return evaluation; - } - - private static FlagValueType flagValueType(final Class targetType) { - if (Boolean.class.equals(targetType)) { - return FlagValueType.BOOLEAN; - } - if (Integer.class.equals(targetType)) { - return FlagValueType.INTEGER; - } - if (Double.class.equals(targetType)) { - return FlagValueType.DOUBLE; - } - if (String.class.equals(targetType)) { - return FlagValueType.STRING; - } - return FlagValueType.OBJECT; + /** Creates a runtime only holding a flag-evaluation pipeline, without starting it. */ + private static FeatureFlagsRuntime capturingRuntime(final FlagEvaluationPipeline evaluations) + throws ReflectiveOperationException { + final Constructor constructor = + FeatureFlagsRuntime.class.getDeclaredConstructor( + ConfigurationService.class, ExposurePipeline.class, FlagEvaluationPipeline.class); + constructor.setAccessible(true); + return constructor.newInstance(mock(ConfigurationService.class), null, evaluations); } static List canonicalTestCases() throws IOException { @@ -386,10 +361,12 @@ private static Object normalize(final Object value) { return value; } - private static final class CapturingEvaluationWriter implements FlagEvaluationWriter { + private static final class CapturingPipeline extends FlagEvaluationPipeline { private final List events; - private CapturingEvaluationWriter(final List events) { + private CapturingPipeline(final List events) { + super( + 1, 1, SECONDS, (route, json) -> {}, emptyMap(), new RuntimeServices(HealthMetrics.NOOP)); this.events = events; } @@ -399,14 +376,57 @@ public boolean hasCapacityForEnqueue() { } @Override - public void countPreQueueOverflow() {} + public void enqueue(final FlagEvalEvent event) { + events.add(event); + } + } + + /** Evaluator with per-case caches and optional in-memory telemetry sinks. */ + private static final class FixtureEvaluator extends DDEvaluator { + private final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(); + private final List exposures = new ArrayList<>(); + private final FeatureFlagsRuntime runtime; + private final boolean captureExposures; + + private FixtureEvaluator( + final ServerConfiguration configuration, + final ExperimentationOptOutMode mode, + final FeatureFlagsRuntime runtime, + final boolean captureExposures) { + super( + mock(Runnable.class), + AgentBridge.NONE, + TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true"), + mode); + this.runtime = runtime; + this.captureExposures = captureExposures; + accept(configuration); + } @Override - public void countContextTruncated(final String reason) {} + public boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext context) { + return hasConfiguration(); + } @Override - public void enqueue(final FlagEvalEvent event) { - events.add(event); + FeatureFlagsRuntime runtime() { + return runtime; + } + + @Override + ExposureDeduplicationCache exposureCache() { + return cache; + } + + @Override + boolean isExposureDeliveryEnabled() { + return captureExposures; + } + + @Override + void recordExposure(final ExposureEvent event) { + exposures.add(event); } } @@ -503,18 +523,22 @@ private static void assertEvents( final List actual, final boolean exact, final String eventType) { - int actualIndex = 0; + final List unconsumed = new ArrayList<>(actual); for (final EventMatcher matcher : expected) { for (int count = 0; count < matcher.count; count++) { - assertTrue(actualIndex < actual.size(), "missing " + eventType + " for " + matcher); - assertTrue( - matcher.matches(actual.get(actualIndex)), - eventType + " at index " + actualIndex + " did not match " + matcher); - actualIndex++; + boolean matched = false; + for (int index = 0; index < unconsumed.size(); index++) { + if (matcher.matches(unconsumed.get(index))) { + unconsumed.remove(index); + matched = true; + break; + } + } + assertTrue(matched, "missing " + eventType + " for " + matcher + "; actual=" + actual); } } if (exact) { - assertEquals(actualIndex, actual.size(), "unexpected " + eventType + " events"); + assertTrue(unconsumed.isEmpty(), "unexpected " + eventType + " events: " + unconsumed); } } From 060484292d84259ceed0d29f48104a8a78c57940 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 5 Nov 2026 15:43:09 -0600 Subject: [PATCH 12/12] test: strengthen dependent flag corpus coverage Select the canonical fixture tests for corpus-only updates and cover selector and count semantics. Environment: Datadog workspace Co-Authored-By: Codex GPT-5 --- .../kotlin/dd-trace-java.ci-jobs.gradle.kts | 8 +++++ .../openfeature/CanonicalFixtureTest.java | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts b/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts index 39a86ce9cad..8bd44f4048d 100644 --- a/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts +++ b/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts @@ -94,6 +94,8 @@ if (gitBaseRefProvider.isPresent) { val projects = subprojects.sortedByDescending { it.projectDir.path.length } val affectedProjects = mutableMapOf>() + val openFeatureCorpus = + rootProject.file("dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data") // Path prefixes mapped to affected task names. A file not matching any of these prefixes will affect all tasks in // the project ("all" can be used a task name to explicitly state the same). Only the first matching prefix is used. @@ -116,6 +118,12 @@ if (gitBaseRefProvider.isPresent) { val task = matchers.find { relPath.startsWith(it["prefix"]!!) }?.get("task") ?: "all" logger.warn("Changed file: ${relativeToGitRoot(f)} in project ${p.path} ($task)") affectedProjects.computeIfAbsent(p) { mutableSetOf() }.add(task) + + if (f == openFeatureCorpus) { + val openFeatureProject = project(":dd-openfeature") + logger.warn("Selecting ${openFeatureProject.path}:testClasses for shared FFE corpus change") + affectedProjects.computeIfAbsent(openFeatureProject) { mutableSetOf() }.add("testClasses") + } } rootProject.extra.set("affectedProjects", affectedProjects) diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java index 1dde391118b..24b88c7346d 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -98,6 +98,41 @@ void eventMatchersGreedilyConsumeMatchingEvents() { () -> exactExpectations.assertMatches(Collections.emptyList(), actual)); } + @Test + void platformSelectorsControlApplicability() { + final Map javaSelector = + Collections.singletonMap("platform", (Object) PLATFORM); + final Map webSelector = Collections.singletonMap("platform", (Object) "web"); + + assertFalse( + isApplicable(Collections.singletonMap("_skip", Collections.singletonList(javaSelector)))); + assertTrue( + isApplicable( + Collections.singletonMap("_include", Collections.singletonList(javaSelector)))); + assertTrue( + isApplicable(Collections.singletonMap("_skip", Collections.singletonList(webSelector)))); + assertFalse( + isApplicable(Collections.singletonMap("_include", Collections.singletonList(webSelector)))); + } + + @Test + void eventMatchersEnforceCounts() { + final Map matcher = new LinkedHashMap<>(); + matcher.put("flag", "expected"); + matcher.put("_count", 2); + final Map expectation = new LinkedHashMap<>(); + expectation.put("evaluationEvents", Collections.singletonList(matcher)); + expectation.put("noUnmatchedEvents", true); + final FixtureExpectations expectations = new FixtureExpectations(expectation); + final FlagEvalEvent event = new FlagEvalEvent("expected", null, null, null, 0, emptyMap()); + + expectations.assertMatches(Collections.emptyList(), Arrays.asList(event, event)); + assertThrows( + AssertionError.class, + () -> + expectations.assertMatches(Collections.emptyList(), Collections.singletonList(event))); + } + @MethodSource("canonicalTestCases") @ParameterizedTest(name = "{0}") void evaluateCanonicalFixture(final FixtureCase testCase) throws Exception {