diff --git a/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 7c5da7f8..93c95acd 100644 --- a/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -142,9 +142,10 @@ public static enum BigSegmentsStatus { private final ErrorKind errorKind; private final Exception exception; private final BigSegmentsStatus bigSegmentsStatus; + private final boolean overrideAffected; private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequisiteKey, boolean inExperiment, - ErrorKind errorKind, Exception exception, BigSegmentsStatus bigSegmentsStatus) { + ErrorKind errorKind, Exception exception, BigSegmentsStatus bigSegmentsStatus, boolean overrideAffected) { this.kind = kind; this.ruleIndex = ruleIndex; this.ruleId = ruleId; @@ -153,18 +154,19 @@ private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequ this.errorKind = errorKind; this.exception = exception; this.bigSegmentsStatus = bigSegmentsStatus; + this.overrideAffected = overrideAffected; } private EvaluationReason(Kind kind) { - this(kind, -1, null, null, NOT_IN_EXPERIMENT, null, null, null); + this(kind, -1, null, null, NOT_IN_EXPERIMENT, null, null, null, false); } private EvaluationReason(Kind kind, boolean inExperiment) { - this(kind, -1, null, null, inExperiment, null, null, null); + this(kind, -1, null, null, inExperiment, null, null, null, false); } private EvaluationReason(ErrorKind errorKind, Exception exception) { - this(Kind.ERROR, -1, null, null, NOT_IN_EXPERIMENT, errorKind, exception, null); + this(Kind.ERROR, -1, null, null, NOT_IN_EXPERIMENT, errorKind, exception, null, false); } /** @@ -267,7 +269,41 @@ public BigSegmentsStatus getBigSegmentsStatus() { */ public EvaluationReason withBigSegmentsStatus(BigSegmentsStatus bigSegmentsStatus) { return new EvaluationReason(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, - exception, bigSegmentsStatus); + exception, bigSegmentsStatus, overrideAffected); + } + + /** + * Whether an override affected this evaluation, directly or transitively. Returns true if the + * evaluated flag came from the SDK's override store. Also returns true if a prerequisite flag at + * any depth, or a segment read during the evaluation, came from that store. Otherwise it returns + * false. + *
+ * In the JSON representation, the {@code overrideAffected} property appears only when this + * value is true. + *
+ * Flag overrides are currently experimental and subject to change. + * + * @return whether an override affected the evaluation + */ + public boolean isOverrideAffected() { + return overrideAffected; + } + + /** + * Returns a copy of this {@link EvaluationReason} with a specific value for + * {@link #isOverrideAffected()}. The copy keeps all other properties of the original reason. + *
+ * Flag overrides are currently experimental and subject to change. + * + * @param overrideAffected the new property value + * @return a reason object with the given value, which is this same instance if the value is unchanged + */ + public EvaluationReason withOverrideAffected(boolean overrideAffected) { + if (this.overrideAffected == overrideAffected) { + return this; + } + return new EvaluationReason(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, + exception, bigSegmentsStatus, overrideAffected); } /** @@ -305,7 +341,8 @@ public boolean equals(Object other) { inExperiment == o.inExperiment && Objects.equals(errorKind, o.errorKind) && Objects.equals(exception, o.exception) && - Objects.equals(bigSegmentsStatus, o.bigSegmentsStatus); + Objects.equals(bigSegmentsStatus, o.bigSegmentsStatus) && + overrideAffected == o.overrideAffected; } return false; } @@ -313,7 +350,7 @@ public boolean equals(Object other) { @Override public int hashCode() { return Objects.hash(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, - exception, bigSegmentsStatus); + exception, bigSegmentsStatus, overrideAffected); } /** @@ -377,7 +414,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { * @return a reason object */ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId, boolean inExperiment) { - return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, inExperiment, null, null, null); + return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, inExperiment, null, null, null, false); } /** @@ -387,7 +424,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId, boolean i * @return a reason object */ public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { - return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, NOT_IN_EXPERIMENT, null, null, null); + return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, NOT_IN_EXPERIMENT, null, null, null, false); } /** diff --git a/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java index 98ec00a2..0bfa1ec4 100644 --- a/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java +++ b/lib/shared/common/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -25,6 +25,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { boolean inExperiment = false; EvaluationReason.ErrorKind errorKind = null; EvaluationReason.BigSegmentsStatus bigSegmentsStatus = null; + boolean overrideAffected = false; reader.beginObject(); while (reader.peek() != JsonToken.END_OBJECT) { @@ -51,6 +52,9 @@ static EvaluationReason parse(JsonReader reader) throws IOException { case "bigSegmentsStatus": bigSegmentsStatus = readEnum(EvaluationReason.BigSegmentsStatus.class, reader); break; + case "overrideAffected": + overrideAffected = reader.nextBoolean(); + break; default: reader.skipValue(); // ignore any unexpected property } @@ -85,9 +89,9 @@ static EvaluationReason parse(JsonReader reader) throws IOException { return null; } if (bigSegmentsStatus != null) { - return reason.withBigSegmentsStatus(bigSegmentsStatus); + reason = reason.withBigSegmentsStatus(bigSegmentsStatus); } - return reason; + return reason.withOverrideAffected(overrideAffected); } @Override @@ -134,6 +138,12 @@ public void write(JsonWriter writer, EvaluationReason reason) throws IOException writer.name("bigSegmentsStatus"); writer.value(reason.getBigSegmentsStatus().name()); } + + // The override marker is written only when it is true, like inExperiment. + if (reason.isOverrideAffected()) { + writer.name("overrideAffected"); + writer.value(true); + } writer.endObject(); } diff --git a/lib/shared/common/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/lib/shared/common/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index d7bd0a18..19c546b7 100644 --- a/lib/shared/common/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/lib/shared/common/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -12,8 +12,11 @@ import static com.launchdarkly.sdk.EvaluationReason.Kind.RULE_MATCH; import static com.launchdarkly.sdk.EvaluationReason.Kind.TARGET_MATCH; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static java.util.Arrays.asList; import org.junit.Test; @@ -84,6 +87,67 @@ public void bigSegmentsStatus() { assertNull(reason.getBigSegmentsStatus()); } + @Test + public void overrideAffectedIsFalseByDefault() { + assertFalse(EvaluationReason.off().isOverrideAffected()); + assertFalse(EvaluationReason.fallthrough().isOverrideAffected()); + assertFalse(EvaluationReason.fallthrough(true).isOverrideAffected()); + assertFalse(EvaluationReason.targetMatch().isOverrideAffected()); + assertFalse(EvaluationReason.ruleMatch(1, "id").isOverrideAffected()); + assertFalse(EvaluationReason.prerequisiteFailed("key").isOverrideAffected()); + assertFalse(EvaluationReason.error(FLAG_NOT_FOUND).isOverrideAffected()); + assertFalse(EvaluationReason.exception(new Exception("sorry")).isOverrideAffected()); + assertFalse(EvaluationReason.fallthrough().withBigSegmentsStatus(HEALTHY).isOverrideAffected()); + } + + @Test + public void withOverrideAffectedKeepsOtherProperties() { + EvaluationReason reason = EvaluationReason.ruleMatch(2, "id", true).withBigSegmentsStatus(STALE); + EvaluationReason marked = reason.withOverrideAffected(true); + + assertTrue(marked.isOverrideAffected()); + assertFalse(reason.isOverrideAffected()); + assertEquals(RULE_MATCH, marked.getKind()); + assertEquals(2, marked.getRuleIndex()); + assertEquals("id", marked.getRuleId()); + assertTrue(marked.isInExperiment()); + assertEquals(STALE, marked.getBigSegmentsStatus()); + + Exception e = new Exception("sorry"); + EvaluationReason markedError = EvaluationReason.exception(e).withOverrideAffected(true); + assertEquals(ERROR, markedError.getKind()); + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, markedError.getErrorKind()); + assertEquals(e, markedError.getException()); + assertTrue(markedError.isOverrideAffected()); + + // The marking survives a later change of another property. + assertTrue(marked.withBigSegmentsStatus(HEALTHY).isOverrideAffected()); + } + + @Test + public void withOverrideAffectedReturnsSameInstanceWhenUnchanged() { + EvaluationReason reason = EvaluationReason.off(); + assertSame(reason, reason.withOverrideAffected(false)); + EvaluationReason marked = reason.withOverrideAffected(true); + assertSame(marked, marked.withOverrideAffected(true)); + assertFalse(marked.withOverrideAffected(false).isOverrideAffected()); + } + + @Test + public void overrideAffectedParticipatesInEquality() { + EvaluationReason plain = EvaluationReason.off(); + EvaluationReason marked = plain.withOverrideAffected(true); + assertNotEquals(plain, marked); + assertEquals(marked, plain.withOverrideAffected(true)); + assertEquals(marked.hashCode(), plain.withOverrideAffected(true).hashCode()); + } + + @Test + public void overrideAffectedDoesNotChangeStringRepresentation() { + assertEquals("OFF", EvaluationReason.off().withOverrideAffected(true).toString()); + assertEquals("RULE_MATCH(1,id)", EvaluationReason.ruleMatch(1, "id").withOverrideAffected(true).toString()); + } + @Test public void simpleStringRepresentations() { assertEquals("OFF", EvaluationReason.off().toString()); @@ -129,7 +193,10 @@ public void equalInstancesAreEqual() { asList(EvaluationReason.ruleMatch(2, "id1"), EvaluationReason.ruleMatch(2, "id1")), asList(EvaluationReason.prerequisiteFailed("a"), EvaluationReason.prerequisiteFailed("a")), asList(EvaluationReason.error(CLIENT_NOT_READY), EvaluationReason.error(CLIENT_NOT_READY)), - asList(EvaluationReason.error(WRONG_TYPE), EvaluationReason.error(WRONG_TYPE)) + asList(EvaluationReason.error(WRONG_TYPE), EvaluationReason.error(WRONG_TYPE)), + asList(EvaluationReason.off().withOverrideAffected(true), EvaluationReason.off().withOverrideAffected(true)), + asList(EvaluationReason.error(WRONG_TYPE).withOverrideAffected(true), + EvaluationReason.error(WRONG_TYPE).withOverrideAffected(true)) ); TestHelpers.doEqualityTests(testValues); } diff --git a/lib/shared/common/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/lib/shared/common/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index 4512f0c3..d94d02e3 100644 --- a/lib/shared/common/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/lib/shared/common/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -59,6 +59,26 @@ public void reasonJsonSerializations() throws Exception { verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":3}"); } + @Test + public void overrideAffectedIsSerializedOnlyWhenTrue() throws Exception { + verifySerializeAndDeserialize(EvaluationReason.off().withOverrideAffected(true), + "{\"kind\":\"OFF\",\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.fallthrough(true).withOverrideAffected(true), + "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":true,\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id").withOverrideAffected(true), + "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.prerequisiteFailed("key").withOverrideAffected(true), + "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\",\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.MALFORMED_FLAG).withOverrideAffected(true), + "{\"kind\":\"ERROR\",\"errorKind\":\"MALFORMED_FLAG\",\"overrideAffected\":true}"); + verifySerializeAndDeserialize(EvaluationReason.fallthrough().withBigSegmentsStatus(HEALTHY).withOverrideAffected(true), + "{\"kind\":\"FALLTHROUGH\",\"bigSegmentsStatus\":\"HEALTHY\",\"overrideAffected\":true}"); + + // A false value is never written, and an explicit false parses the same as an absent property. + verifySerialize(EvaluationReason.off().withOverrideAffected(false), "{\"kind\":\"OFF\"}"); + verifyDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\",\"overrideAffected\":false}"); + } + @Test public void errorSerializationWithException() throws Exception { // We do *not* want the JSON representation to include the exception, because that is used in events, and