diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java index c08ba26..5e723ec 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java @@ -37,7 +37,8 @@ public class TestService { "event-sampling", "inline-context", "anonymous-redaction", - "evaluation-hooks" + "evaluation-hooks", + "client-prereq-events" }; static final Gson gson = new GsonBuilder().serializeNulls().create(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java index 0d0e879..8611afb 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java @@ -6,6 +6,10 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; /** @@ -30,6 +34,9 @@ final class EvalResult { private final EvaluationDetail asString; private final boolean forceReasonTracking; + // A list of prerequisites evaluation records evaluated as part of obtaining this result. + private List prerequisiteEvalRecords = new ArrayList<>(0); // 0 initial capacity uses a static instance for performance + /** * Constructs an instance that wraps the specified EvaluationDetail and also precomputes * any appropriate type-specific variants (asBoolean, etc.). @@ -100,6 +107,7 @@ private EvalResult(EvalResult from, EvaluationReason newReason) { this.asDouble = transformReason(from.asDouble, newReason); this.asString = transformReason(from.asString, newReason); this.forceReasonTracking = from.forceReasonTracking; + this.prerequisiteEvalRecords = from.prerequisiteEvalRecords; } private EvalResult(EvalResult from, boolean newForceTracking) { @@ -109,6 +117,17 @@ private EvalResult(EvalResult from, boolean newForceTracking) { this.asDouble = from.asDouble; this.asString = from.asString; this.forceReasonTracking = newForceTracking; + this.prerequisiteEvalRecords = from.prerequisiteEvalRecords; + } + + private EvalResult(EvalResult from, List prerequisiteEvalRecords) { + this.anyType = from.anyType; + this.asBoolean = from.asBoolean; + this.asInteger = from.asInteger; + this.asDouble = from.asDouble; + this.asString = from.asString; + this.forceReasonTracking = from.forceReasonTracking; + this.prerequisiteEvalRecords = prerequisiteEvalRecords; } /** @@ -208,6 +227,8 @@ public EvaluationDetail getAsString() { * @return true if reason tracking is required for this result */ public boolean isForceReasonTracking() { return forceReasonTracking; } + + public List getPrerequisiteEvalRecords() { return prerequisiteEvalRecords; } /** * Returns a transformed copy of this EvalResult with a different evaluation reason. @@ -226,6 +247,10 @@ public EvalResult withReason(EvaluationReason newReason) { public EvalResult withForceReasonTracking(boolean newValue) { return this.forceReasonTracking == newValue ? this : new EvalResult(this, newValue); } + + public EvalResult withPrerequisiteEvalRecords(List newValue) { + return this.prerequisiteEvalRecords == newValue ? this : new EvalResult(this, newValue); + } @Override public boolean equals(Object other) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index b605c16..845d4aa 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -122,6 +122,7 @@ private static class EvaluatorState { private EvaluationReason.BigSegmentsStatus bigSegmentsStatus = null; private FeatureFlag originalFlag = null; private List prerequisiteStack = null; + private List prerequisiteEvalRecords = new ArrayList<>(0); // 0 initial capacity uses a static instance for performance private List segmentStack = null; } @@ -150,10 +151,15 @@ EvalResult evaluate(FeatureFlag flag, LDContext context, @Nonnull EvaluationReco EvalResult result = evaluateInternal(flag, context, recorder, state); if (state.bigSegmentsStatus != null) { - return result.withReason( + result = result.withReason( result.getReason().withBigSegmentsStatus(state.bigSegmentsStatus) ); } + + if (state.prerequisiteEvalRecords != null && !state.prerequisiteEvalRecords.isEmpty()) { + result = result.withPrerequisiteEvalRecords(state.prerequisiteEvalRecords); + } + return result; } catch (EvaluationException e) { logger.error("Could not evaluate flag \"{}\": {}", flag.getKey(), e.getMessage()); @@ -161,6 +167,15 @@ EvalResult evaluate(FeatureFlag flag, LDContext context, @Nonnull EvaluationReco } } + /** + * Internal evaluation function that may be called multiple times during a flag evaluation. + * + * @param flag that to evaluate + * @param context to use for evaluation + * @param recorder that will be used to record evaluation events + * @param state for mutable values needed during evaluation + * @return the evaluation result + */ private EvalResult evaluateInternal(FeatureFlag flag, LDContext context, @Nonnull EvaluationRecorder recorder, EvaluatorState state) { if (!flag.isOn()) { return EvaluatorHelpers.offResult(flag); @@ -237,6 +252,7 @@ private EvalResult checkPrerequisites(FeatureFlag flag, LDContext context, @Nonn if (!prereqFeatureFlag.isOn() || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { prereqOk = false; } + state.prerequisiteEvalRecords.add(new PrerequisiteEvalRecord(prereqFeatureFlag, flag, prereqEvalResult)); recorder.recordPrerequisiteEvaluation(prereqFeatureFlag, flag, context, prereqEvalResult); } if (!prereqOk) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index e48f037..beafaae 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -12,9 +12,12 @@ import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstanceWithNullsAllowed; @@ -48,9 +51,10 @@ static class FlagMetadata { final boolean trackEvents; final boolean trackReason; final Long debugEventsUntilDate; + final List prerequisites; FlagMetadata(LDValue value, Integer variation, EvaluationReason reason, Integer version, - boolean trackEvents, boolean trackReason, Long debugEventsUntilDate) { + boolean trackEvents, boolean trackReason, Long debugEventsUntilDate, List prerequisites) { this.value = LDValue.normalize(value); this.variation = variation; this.reason = reason; @@ -58,8 +62,9 @@ static class FlagMetadata { this.trackEvents = trackEvents; this.trackReason = trackReason; this.debugEventsUntilDate = debugEventsUntilDate; + this.prerequisites = prerequisites; } - + @Override public boolean equals(Object other) { if (other instanceof FlagMetadata) { @@ -70,14 +75,15 @@ public boolean equals(Object other) { Objects.equals(version, o.version) && trackEvents == o.trackEvents && trackReason == o.trackReason && - Objects.equals(debugEventsUntilDate, o.debugEventsUntilDate); + Objects.equals(debugEventsUntilDate, o.debugEventsUntilDate) && + Objects.equals(prerequisites, o.prerequisites); } return false; } - + @Override public int hashCode() { - return Objects.hash(variation, version, trackEvents, trackReason, debugEventsUntilDate); + return Objects.hash(value, variation, reason, version, trackEvents, trackReason, debugEventsUntilDate, prerequisites); } } @@ -206,7 +212,8 @@ public Builder valid(boolean valid) { * @param reason the evaluation reason * @param flagVersion the current flag version * @param trackEvents true if full event tracking is turned on for this flag - * @param debugEventsUntilDate if set, event debugging is turned until this time (millisecond timestamp) + * @param debugEventsUntilDate if set, event debugging is turned until this time (millisecond timestamp) + * @param prerequisites list of flag keys of the top level prerequisite flags evaluated as part of this evaluation * @return the builder */ public Builder add( @@ -216,9 +223,10 @@ public Builder add( EvaluationReason reason, int flagVersion, boolean trackEvents, - Long debugEventsUntilDate + Long debugEventsUntilDate, + List prerequisites ) { - return add(flagKey, value, variationIndex, reason, flagVersion, trackEvents, false, debugEventsUntilDate); + return add(flagKey, value, variationIndex, reason, flagVersion, trackEvents, false, debugEventsUntilDate, prerequisites); } /** @@ -236,7 +244,8 @@ public Builder add( * @param flagVersion the current flag version * @param trackEvents true if full event tracking is turned on for this flag * @param trackReason true if evaluation reasons must be included due to experimentation - * @param debugEventsUntilDate if set, event debugging is turned until this time (millisecond timestamp) + * @param debugEventsUntilDate if set, event debugging is turned until this time (millisecond timestamp) + * @param prerequisites list of flag keys of the top level prerequisite flags evaluated as part of this evaluation * @return the builder */ public Builder add( @@ -247,7 +256,8 @@ public Builder add( int flagVersion, boolean trackEvents, boolean trackReason, - Long debugEventsUntilDate + Long debugEventsUntilDate, + List prerequisites ) { final boolean flagIsTracked = trackEvents || (debugEventsUntilDate != null && debugEventsUntilDate > System.currentTimeMillis()); @@ -259,8 +269,8 @@ public Builder add( wantDetails ? Integer.valueOf(flagVersion) : null, trackEvents, trackReason, - debugEventsUntilDate - ); + debugEventsUntilDate, + prerequisites); flagMetadata.put(flagKey, data); return this; } @@ -274,7 +284,11 @@ Builder addFlag(DataModel.FeatureFlag flag, EvalResult eval) { flag.getVersion(), flag.isTrackEvents() || eval.isForceReasonTracking(), eval.isForceReasonTracking(), - flag.getDebugEventsUntilDate() + flag.getDebugEventsUntilDate(), + eval.getPrerequisiteEvalRecords().stream() + .filter(record -> record.prereqOfFlag.getKey() == flag.getKey()) // only include top level prereqs + .map(record -> record.flag.getKey()) // map from prereq record to prereq key + .collect(Collectors.toList()) ); } @@ -331,6 +345,14 @@ public void write(JsonWriter out, FeatureFlagsState state) throws IOException { out.name("debugEventsUntilDate"); out.value(meta.debugEventsUntilDate.longValue()); } + if (meta.prerequisites != null && !meta.prerequisites.isEmpty()) { + out.name("prerequisites"); + out.beginArray(); + for (String s: meta.prerequisites) { + out.value(s); + } + out.endArray(); + } out.endObject(); } out.endObject(); @@ -377,8 +399,8 @@ public FeatureFlagsState read(JsonReader in) throws IOException { m0.version, m0.trackEvents, m0.trackReason, - m0.debugEventsUntilDate - ); + m0.debugEventsUntilDate, + m0.prerequisites != null ? m0.prerequisites : new ArrayList<>(0)); allFlagMetadata.put(e.getKey(), m1); } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PrerequisiteEvalRecord.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PrerequisiteEvalRecord.java new file mode 100644 index 0000000..7bb57b0 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PrerequisiteEvalRecord.java @@ -0,0 +1,13 @@ +package com.launchdarkly.sdk.server; + +public class PrerequisiteEvalRecord { + public final DataModel.FeatureFlag flag; + public final DataModel.FeatureFlag prereqOfFlag; + public final EvalResult result; + + public PrerequisiteEvalRecord(DataModel.FeatureFlag flag, DataModel.FeatureFlag prereqOfFlag, EvalResult result) { + this.flag = flag; + this.prereqOfFlag = prereqOfFlag; + this.result = result; + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorPrerequisiteTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorPrerequisiteTest.java index 38a716f..05f184b 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorPrerequisiteTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorPrerequisiteTest.java @@ -6,9 +6,6 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Prerequisite; -import com.launchdarkly.sdk.server.EvaluatorTestUtil.PrereqEval; -import com.launchdarkly.sdk.server.EvaluatorTestUtil.PrereqRecorder; - import org.junit.Test; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_USER; @@ -44,6 +41,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, expectedReason), result); + assertEquals(0, result.getPrerequisiteEvalRecords().size()); } @Test @@ -58,18 +56,18 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio // note that even though it returns the desired variation, it is still off and therefore not a match .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - PrereqRecorder recordPrereqs = new PrereqRecorder(); - EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, expectedReason), result); - assertEquals(1, Iterables.size(recordPrereqs.evals)); - PrereqEval eval = recordPrereqs.evals.get(0); + assertEquals(1, Iterables.size(result.getPrerequisiteEvalRecords())); + PrerequisiteEvalRecord eval = result.getPrerequisiteEvalRecords().get(0); assertEquals(f1, eval.flag); assertEquals(f0, eval.prereqOfFlag); assertEquals(GREEN_VARIATION, eval.result.getVariationIndex()); assertEquals(GREEN_VALUE, eval.result.getValue()); + assertEquals(1, result.getPrerequisiteEvalRecords().size()); } @Test @@ -83,18 +81,18 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep .fallthroughVariation(RED_VARIATION) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - PrereqRecorder recordPrereqs = new PrereqRecorder(); - EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, expectedReason), result); - - assertEquals(1, Iterables.size(recordPrereqs.evals)); - PrereqEval eval = recordPrereqs.evals.get(0); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvalRecords())); + PrerequisiteEvalRecord eval = result.getPrerequisiteEvalRecords().get(0); assertEquals(f1, eval.flag); assertEquals(f0, eval.prereqOfFlag); assertEquals(RED_VARIATION, eval.result.getVariationIndex()); assertEquals(RED_VALUE, eval.result.getValue()); + assertEquals(1, result.getPrerequisiteEvalRecords().size()); } @Test @@ -145,13 +143,12 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr .version(2) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - PrereqRecorder recordPrereqs = new PrereqRecorder(); - EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); assertEquals(EvalResult.of(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result); - assertEquals(1, Iterables.size(recordPrereqs.evals)); - PrereqEval eval = recordPrereqs.evals.get(0); + assertEquals(1, Iterables.size(result.getPrerequisiteEvalRecords())); + PrerequisiteEvalRecord eval = result.getPrerequisiteEvalRecords().get(0); assertEquals(f1, eval.flag); assertEquals(f0, eval.prereqOfFlag); assertEquals(GREEN_VARIATION, eval.result.getVariationIndex()); @@ -174,26 +171,63 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio .fallthroughVariation(GREEN_VARIATION) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1, f2).build(); - PrereqRecorder recordPrereqs = new PrereqRecorder(); - EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); assertEquals(EvalResult.of(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result); - assertEquals(2, Iterables.size(recordPrereqs.evals)); + assertEquals(2, Iterables.size(result.getPrerequisiteEvalRecords())); - PrereqEval eval0 = recordPrereqs.evals.get(0); + PrerequisiteEvalRecord eval0 = result.getPrerequisiteEvalRecords().get(0); assertEquals(f2, eval0.flag); assertEquals(f1, eval0.prereqOfFlag); assertEquals(GREEN_VARIATION, eval0.result.getVariationIndex()); assertEquals(GREEN_VALUE, eval0.result.getValue()); - PrereqEval eval1 = recordPrereqs.evals.get(1); + PrerequisiteEvalRecord eval1 = result.getPrerequisiteEvalRecords().get(1); assertEquals(f1, eval1.flag); assertEquals(f0, eval1.prereqOfFlag); assertEquals(GREEN_VARIATION, eval1.result.getVariationIndex()); assertEquals(GREEN_VALUE, eval1.result.getValue()); } + @Test + public void prerequisitesListIsAccurateWhenShortCircuiting() throws Exception { + FeatureFlag f0 = buildThreeWayFlag("feature") + .on(true) + .prerequisites(prerequisite("prereq1", GREEN_VARIATION), prerequisite("prereq2", GREEN_VARIATION), prerequisite("prereq3", GREEN_VARIATION)) + .build(); + FeatureFlag f1 = buildRedGreenFlag("prereq1") + .on(true) + .fallthroughVariation(GREEN_VARIATION) + .build(); + FeatureFlag f2 = buildRedGreenFlag("prereq2") + .on(true) + .fallthroughVariation(RED_VARIATION) + .build(); + FeatureFlag f3 = buildRedGreenFlag("prereq3") + .on(true) + .fallthroughVariation(GREEN_VARIATION) + .build(); + + Evaluator e = evaluatorBuilder().withStoredFlags(f0, f1, f2, f3).build(); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); + + assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, EvaluationReason.prerequisiteFailed("prereq2")), result); + assertEquals(2, Iterables.size(result.getPrerequisiteEvalRecords())); // prereq 1 and 2 are reached, but 2 fails, so 3 is not checked. + + PrerequisiteEvalRecord prereq1Eval = result.getPrerequisiteEvalRecords().get(0); + assertEquals(f1, prereq1Eval.flag); + assertEquals(f0, prereq1Eval.prereqOfFlag); + assertEquals(GREEN_VARIATION, prereq1Eval.result.getVariationIndex()); + assertEquals(GREEN_VALUE, prereq1Eval.result.getValue()); + + PrerequisiteEvalRecord prereq2Eval = result.getPrerequisiteEvalRecords().get(1); + assertEquals(f2, prereq2Eval.flag); + assertEquals(f0, prereq2Eval.prereqOfFlag); + assertEquals(RED_VARIATION, prereq2Eval.result.getVariationIndex()); + assertEquals(RED_VALUE, prereq2Eval.result.getValue()); + } + @Test public void prerequisiteCycleDetection() { for (int depth = 1; depth <= 4; depth++) { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 0ecafe1..217fd77 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -9,8 +9,6 @@ import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; -import com.launchdarkly.sdk.server.EvaluatorTestUtil.PrereqEval; -import com.launchdarkly.sdk.server.EvaluatorTestUtil.PrereqRecorder; import org.junit.Test; @@ -298,14 +296,13 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio // note that even though it returns the desired variation, it is still off and therefore not a match .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - PrereqRecorder recordPrereqs = new PrereqRecorder(); - EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, expectedReason), result); - assertEquals(1, Iterables.size(recordPrereqs.evals)); - PrereqEval eval = recordPrereqs.evals.get(0); + assertEquals(1, Iterables.size(result.getPrerequisiteEvalRecords())); + PrerequisiteEvalRecord eval = result.getPrerequisiteEvalRecords().get(0); assertEquals(f1, eval.flag); assertEquals(f0, eval.prereqOfFlag); assertEquals(GREEN_VARIATION, eval.result.getVariationIndex()); @@ -323,14 +320,13 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep .fallthroughVariation(RED_VARIATION) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - PrereqRecorder recordPrereqs = new PrereqRecorder(); - EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, expectedReason), result); - assertEquals(1, Iterables.size(recordPrereqs.evals)); - PrereqEval eval = recordPrereqs.evals.get(0); + assertEquals(1, Iterables.size(result.getPrerequisiteEvalRecords())); + PrerequisiteEvalRecord eval = result.getPrerequisiteEvalRecords().get(0); assertEquals(f1, eval.flag); assertEquals(f0, eval.prereqOfFlag); assertEquals(RED_VARIATION, eval.result.getVariationIndex()); @@ -385,13 +381,12 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr .version(2) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - PrereqRecorder recordPrereqs = new PrereqRecorder(); - EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); assertEquals(EvalResult.of(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result); - assertEquals(1, Iterables.size(recordPrereqs.evals)); - PrereqEval eval = recordPrereqs.evals.get(0); + assertEquals(1, Iterables.size(result.getPrerequisiteEvalRecords())); + PrerequisiteEvalRecord eval = result.getPrerequisiteEvalRecords().get(0); assertEquals(f1, eval.flag); assertEquals(f0, eval.prereqOfFlag); assertEquals(GREEN_VARIATION, eval.result.getVariationIndex()); @@ -414,20 +409,19 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio .fallthroughVariation(GREEN_VARIATION) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1, f2).build(); - PrereqRecorder recordPrereqs = new PrereqRecorder(); - EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); + EvalResult result = e.evaluate(f0, BASE_USER, new EvaluationRecorder(){}); assertEquals(EvalResult.of(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result); - assertEquals(2, Iterables.size(recordPrereqs.evals)); + assertEquals(2, Iterables.size(result.getPrerequisiteEvalRecords())); - PrereqEval eval0 = recordPrereqs.evals.get(0); + PrerequisiteEvalRecord eval0 = result.getPrerequisiteEvalRecords().get(0); assertEquals(f2, eval0.flag); assertEquals(f1, eval0.prereqOfFlag); assertEquals(GREEN_VARIATION, eval0.result.getVariationIndex()); assertEquals(GREEN_VALUE, eval0.result.getValue()); - PrereqEval eval1 = recordPrereqs.evals.get(1); + PrerequisiteEvalRecord eval1 = result.getPrerequisiteEvalRecords().get(1); assertEquals(f1, eval1.flag); assertEquals(f0, eval1.prereqOfFlag); assertEquals(GREEN_VARIATION, eval1.result.getVariationIndex()); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java index 33849be..304b2d0 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -137,28 +137,4 @@ public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfF } }; } - - public static final class PrereqEval { - public final FeatureFlag flag; - public final FeatureFlag prereqOfFlag; - public final LDContext context; - public final EvalResult result; - - public PrereqEval(FeatureFlag flag, FeatureFlag prereqOfFlag, LDContext context, EvalResult result) { - this.flag = flag; - this.prereqOfFlag = prereqOfFlag; - this.context = context; - this.result = result; - } - } - - public static final class PrereqRecorder implements EvaluationRecorder { - public final List evals = new ArrayList<>(); - - @Override - public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfFlag, LDContext context, - EvalResult result) { - evals.add(new PrereqEval(flag, prereqOfFlag, context, result)); - } - } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index dc67b32..9ccab64 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -12,6 +12,7 @@ import org.junit.Test; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; @@ -30,7 +31,7 @@ public class FeatureFlagsStateTest { @Test public void canGetFlagValue() { FeatureFlagsState state = FeatureFlagsState.builder() - .add("key", LDValue.of("value"), 1, null, 10, false, null) + .add("key", LDValue.of("value"), 1, null, 10, false, null, null) .build(); assertEquals(LDValue.of("value"), state.getFlagValue("key")); @@ -46,7 +47,7 @@ public void unknownFlagReturnsNullValue() { @Test public void canGetFlagReason() { FeatureFlagsState state = FeatureFlagsState.builder(WITH_REASONS) - .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, null) + .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, null, null) .build(); assertEquals(EvaluationReason.off(), state.getFlagReason("key")); @@ -62,7 +63,7 @@ public void unknownFlagReturnsNullReason() { @Test public void reasonIsNullIfReasonsWereNotRecorded() { FeatureFlagsState state = FeatureFlagsState.builder() - .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, null) + .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, null, null) .build(); assertNull(state.getFlagReason("key")); @@ -71,7 +72,7 @@ public void reasonIsNullIfReasonsWereNotRecorded() { @Test public void flagIsTreatedAsTrackedIfDebugEventsUntilDateIsInFuture() { FeatureFlagsState state = FeatureFlagsState.builder(WITH_REASONS, DETAILS_ONLY_FOR_TRACKED_FLAGS) - .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, System.currentTimeMillis() + 1000000) + .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, System.currentTimeMillis() + 1000000, null) .build(); assertNotNull(state.getFlagReason("key")); @@ -80,7 +81,7 @@ public void flagIsTreatedAsTrackedIfDebugEventsUntilDateIsInFuture() { @Test public void flagIsNotTreatedAsTrackedIfDebugEventsUntilDateIsInPast() { FeatureFlagsState state = FeatureFlagsState.builder(WITH_REASONS, DETAILS_ONLY_FOR_TRACKED_FLAGS) - .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, System.currentTimeMillis() - 1000000) + .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, System.currentTimeMillis() - 1000000, null) .build(); assertNull(state.getFlagReason("key")); @@ -89,7 +90,7 @@ public void flagIsNotTreatedAsTrackedIfDebugEventsUntilDateIsInPast() { @Test public void flagCanHaveNullValue() { FeatureFlagsState state = FeatureFlagsState.builder() - .add("key", LDValue.ofNull(), 1, null, 10, false, null) + .add("key", LDValue.ofNull(), 1, null, 10, false, null, null) .build(); assertEquals(LDValue.ofNull(), state.getFlagValue("key")); @@ -98,8 +99,8 @@ public void flagCanHaveNullValue() { @Test public void canConvertToValuesMap() { FeatureFlagsState state = FeatureFlagsState.builder() - .add("key1", LDValue.of("value1"), 0, null, 10, false, null) - .add("key2", LDValue.of("value2"), 1, null, 10, false, null) + .add("key1", LDValue.of("value1"), 0, null, 10, false, null, null) + .add("key2", LDValue.of("value2"), 1, null, 10, false, null, null) .build(); ImmutableMap expected = ImmutableMap.of("key1", LDValue.of("value1"), "key2", LDValue.of("value2")); @@ -109,19 +110,19 @@ public void canConvertToValuesMap() { @Test public void equalInstancesAreEqual() { FeatureFlagsState justOneFlag = FeatureFlagsState.builder(WITH_REASONS) - .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null) + .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null, null) .build(); FeatureFlagsState sameFlagsDifferentInstances1 = FeatureFlagsState.builder(WITH_REASONS) - .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null) - .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null) + .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null, null) + .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null, null) .build(); FeatureFlagsState sameFlagsDifferentInstances2 = FeatureFlagsState.builder(WITH_REASONS) - .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null) - .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null) + .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null, null) + .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null, null) .build(); FeatureFlagsState sameFlagsDifferentMetadata = FeatureFlagsState.builder(WITH_REASONS) - .add("key1", LDValue.of("value1"), 1, EvaluationReason.off(), 10, false, null) - .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null) + .add("key1", LDValue.of("value1"), 1, EvaluationReason.off(), 10, false, null, null) + .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null, null) .build(); FeatureFlagsState noFlagsButValid = FeatureFlagsState.builder(WITH_REASONS).build(); FeatureFlagsState noFlagsAndNotValid = FeatureFlagsState.builder(WITH_REASONS).valid(false).build(); @@ -147,8 +148,10 @@ public void equalMetadataInstancesAreEqual() { for (boolean trackEvents: new boolean[] { false, true }) { for (boolean trackReason: new boolean[] { false, true }) { for (Long debugEventsUntilDate: new Long[] { null, 1000L, 1001L }) { - allPermutations.add(() -> new FeatureFlagsState.FlagMetadata( - value, variation, reason, version, trackEvents, trackReason, debugEventsUntilDate)); + for (List prerequisites: Arrays.asList(null, Arrays.asList("prereq1"), Arrays.asList("prereq2", "prereq3"))) { + allPermutations.add(() -> new FeatureFlagsState.FlagMetadata( + value, variation, reason, version, trackEvents, trackReason, debugEventsUntilDate, prerequisites)); + } } } } @@ -174,8 +177,9 @@ public void canConvertToJson() { @Test public void canConvertFromJson() throws SerializationException { + FeatureFlagsState expectedState = makeInstanceForSerialization(); FeatureFlagsState state = JsonSerialization.deserialize(makeExpectedJsonSerialization(), FeatureFlagsState.class); - assertEquals(makeInstanceForSerialization(), state); + assertEquals(expectedState, state); } private static FeatureFlagsState makeInstanceForSerialization() { @@ -220,6 +224,7 @@ public void canSerializeAndDeserializeWithJackson() throws Exception { assertJsonEquals(makeExpectedJsonSerialization(), actualJsonString); FeatureFlagsState state = jacksonMapper.readValue(makeExpectedJsonSerialization(), FeatureFlagsState.class); - assertEquals(makeInstanceForSerialization(), state); + FeatureFlagsState expected = makeInstanceForSerialization(); + assertEquals(expected, state); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index b284afa..30080e6 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -26,6 +26,7 @@ import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; @@ -34,6 +35,7 @@ import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonIncludes; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -653,4 +655,67 @@ public void allFlagsStateReturnsEmptyStateIfClientAndStoreAreNotInitialized() th assertFalse(state.isValid()); } } + + @Test + public void allFlagStateIncludesPrerequisites() throws Exception { + DataModel.FeatureFlag flag1 = flagBuilder("flagA") + .version(1) + .on(true) + .variations(LDValue.of("off"), LDValue.of("value1")) + .fallthrough(fallthroughVariation(0)) + .trackEvents(false) + .build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagAB") + .version(2) + .on(true) + .variations(LDValue.of("off"), LDValue.of("value2")) + .fallthrough(fallthroughVariation(0)) + .prerequisites(prerequisite("flagA", 0)) + .trackEvents(false) + .build(); + DataModel.FeatureFlag flag3 = flagBuilder("flagABC") + .version(3) + .on(true) + .variations(LDValue.of("off"), LDValue.of("value3")) + .fallthrough(fallthroughVariation(0)) + .prerequisites(prerequisite("flagAB", 0)) + .trackEvents(false) + .build(); + DataModel.FeatureFlag flag4 = flagBuilder("flagAD") + .version(4) + .on(true) + .variations(LDValue.of("off"), LDValue.of("value4")) + .fallthrough(fallthroughVariation(0)) + .prerequisites(prerequisite("flagA", 0)) + .trackEvents(false) + .build(); + DataModel.FeatureFlag flagTwoPrereqs = flagBuilder("flagTwoPrereqs") + .version(4) + .on(true) + .variations(LDValue.of("off"), LDValue.of("value5")) + .fallthrough(fallthroughVariation(0)) + .prerequisites(prerequisite("flagA", 0), prerequisite("flagAB", 0)) + .trackEvents(false) + .build(); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + upsertFlag(dataStore, flag3); + upsertFlag(dataStore, flag4); + upsertFlag(dataStore, flagTwoPrereqs); + + FeatureFlagsState state = client.allFlagsState(context); + assertTrue(state.isValid()); + + String outputJson = gson.toJson(state); + String expectedPart1 = "{\"$flagsState\":{\"flagA\":{}}}"; + String expectedPart2 = "{\"$flagsState\":{\"flagAB\":{\"prerequisites\":[\"flagA\"]}}}"; + String expectedPart3 = "{\"$flagsState\":{\"flagABC\":{\"prerequisites\":[\"flagAB\"]}}}"; + String expectedPart4 = "{\"$flagsState\":{\"flagAD\":{\"prerequisites\":[\"flagA\"]}}}"; + String expectedPart5 = "{\"$flagsState\":{\"flagTwoPrereqs\":{\"prerequisites\":[\"flagA\",\"flagAB\"]}}}"; + assertJsonIncludes(expectedPart1, outputJson); + assertJsonIncludes(expectedPart2, outputJson); + assertJsonIncludes(expectedPart3, outputJson); + assertJsonIncludes(expectedPart4, outputJson); + assertJsonIncludes(expectedPart5, outputJson); + } }