Skip to content

Commit

Permalink
feat: Adds support for client-side prerequisite events (#279)
Browse files Browse the repository at this point in the history
**Requirements**

- [x] I have added test coverage for new or changed functionality
- [x] I have followed the repository's [pull request submission
guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests)
- [x] I have validated my changes against all supported platform
versions

**Related issues**

SDK-683
  • Loading branch information
tanderson-ld authored Oct 18, 2024
1 parent cc5ee7e commit 8d59b96
Show file tree
Hide file tree
Showing 12 changed files with 109 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public class TestService extends NanoHTTPD {
"tags",
"auto-env-attributes",
"inline-context",
"anonymous-redaction"
"anonymous-redaction",
"client-prereq-events"
};
private static final String MIME_JSON = "application/json";
static final Gson gson = new GsonBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,48 @@ public void variationFlagTrackReasonGeneratesEventWithReason() throws IOExceptio
}
}

@Test
public void flagEvaluationWithPrereqProducesPrereqEvents() throws IOException, InterruptedException {
try (MockWebServer mockEventsServer = new MockWebServer()) {
mockEventsServer.start();
// Enqueue a successful empty response
mockEventsServer.enqueue(new MockResponse());

// Setup flag store with test flag
Flag flagA = new FlagBuilder("flagA").version(1)
.variation(1).value(LDValue.of(true)).reason(EvaluationReason.targetMatch()).build();
Flag flagAB = new FlagBuilder("flagAB").prerequisites(new String[]{"flagA"}).version(1)
.variation(1).value(LDValue.of(true)).reason(EvaluationReason.targetMatch()).build();
Flag flagAC = new FlagBuilder("flagAC").prerequisites(new String[]{"flagA"}).version(1)
.variation(1).value(LDValue.of(true)).reason(EvaluationReason.targetMatch()).build();
Flag flagABD = new FlagBuilder("flagABD").prerequisites(new String[]{"flagAB"}).version(1)
.variation(1).value(LDValue.of(true)).reason(EvaluationReason.targetMatch()).build();
PersistentDataStore store = new InMemoryPersistentDataStore();
TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flagA);
TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flagAB);
TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flagAC);
TestUtil.writeFlagUpdateToStore(store, mobileKey, ldContext, flagABD);
LDConfig ldConfig = baseConfigBuilder(mockEventsServer)
.persistentDataStore(store).build();

try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) {
assertTrue(client.boolVariation("flagA", false));
assertTrue(client.boolVariation("flagAB", false));
assertTrue(client.boolVariation("flagAC", false));
assertTrue(client.boolVariation("flagABD", false));
client.blockingFlush();

LDValue[] events = getEventsFromLastRequest(mockEventsServer, 2);
LDValue summaryEvent = events[1];
assertSummaryEvent(summaryEvent);
assertEquals(LDValue.of(4), summaryEvent.get("features").get("flagA").get("counters").get(0).get("count"));
assertEquals(LDValue.of(2), summaryEvent.get("features").get("flagAB").get("counters").get(0).get("count"));
assertEquals(LDValue.of(1), summaryEvent.get("features").get("flagAC").get("counters").get(0).get("count"));
assertEquals(LDValue.of(1), summaryEvent.get("features").get("flagABD").get("counters").get(0).get("count"));
}
}
}

@Test
public void additionalHeadersIncludedInEventsRequest() throws IOException, InterruptedException {
try (MockWebServer mockEventsServer = new MockWebServer()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public static final class Flag {
private final Boolean trackEvents;
private final Boolean trackReason;
private final Long debugEventsUntilDate;
private final String[] prerequisites;
private final Boolean deleted;

private Flag(
Expand All @@ -51,6 +52,7 @@ private Flag(
boolean trackEvents,
boolean trackReason,
Long debugEventsUntilDate,
String[] prerequisites,
boolean deleted
) {
this.key = key;
Expand All @@ -62,6 +64,7 @@ private Flag(
this.trackEvents = trackEvents ? Boolean.TRUE : null;
this.trackReason = trackReason ? Boolean.TRUE : null;
this.debugEventsUntilDate = debugEventsUntilDate;
this.prerequisites = prerequisites;
this.deleted = deleted ? Boolean.TRUE : null;
}

Expand All @@ -76,6 +79,7 @@ private Flag(
* @param trackReason true if events must include evaluation reasons
* @param debugEventsUntilDate non-null if debugging is enabled
* @param reason evaluation reason of the result, or null if not available
* @param prerequisites flag keys of prerequisites
*/
public Flag(
@NonNull String key,
Expand All @@ -86,9 +90,10 @@ public Flag(
boolean trackEvents,
boolean trackReason,
@Nullable Long debugEventsUntilDate,
@Nullable EvaluationReason reason
@Nullable EvaluationReason reason,
@Nullable String[] prerequisites
) {
this(key, value, version, flagVersion, variation, reason, trackEvents, trackReason, debugEventsUntilDate, false);
this(key, value, version, flagVersion, variation, reason, trackEvents, trackReason, debugEventsUntilDate, prerequisites, false);
}

/**
Expand All @@ -97,7 +102,7 @@ public Flag(
* @return a placeholder {@link Flag} to represent a deleted flag
*/
public static Flag deletedItemPlaceholder(@NonNull String key, int version) {
return new Flag(key, null, version, null, null, null, false, false, null, true);
return new Flag(key, null, version, null, null, null, false, false, null, null, true);
}

String getKey() {
Expand All @@ -122,6 +127,7 @@ Integer getVariation() {
return variation;
}

@Nullable
EvaluationReason getReason() {
return reason;
}
Expand All @@ -132,6 +138,7 @@ boolean isTrackEvents() {

boolean isTrackReason() { return trackReason != null && trackReason.booleanValue(); }

@Nullable
Long getDebugEventsUntilDate() {
return debugEventsUntilDate;
}
Expand All @@ -140,6 +147,11 @@ int getVersionForEvents() {
return flagVersion == null ? version : flagVersion.intValue();
}

@Nullable
String[] getPrerequisites() {
return prerequisites;
}

boolean isDeleted() {
return deleted != null && deleted.booleanValue();
}
Expand All @@ -161,6 +173,7 @@ public boolean equals(Object other) {
trackEvents == o.trackEvents &&
trackReason == o.trackReason &&
Objects.equals(debugEventsUntilDate, o.debugEventsUntilDate) &&
Objects.equals(prerequisites, o.prerequisites) &&
deleted == o.deleted;
}
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public static EnvironmentData fromJson(String json) throws SerializationExceptio
if (f.getKey() == null) {
f = new Flag(e.getKey(), f.getValue(), f.getVersion(), f.getFlagVersion(),
f.getVariation(), f.isTrackEvents(), f.isTrackReason(), f.getDebugEventsUntilDate(),
f.getReason());
f.getReason(), f.getPrerequisites());
dataMap.put(e.getKey(), f);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ private <T> EvaluationDetail<T> convertDetailType(EvaluationDetail<LDValue> deta
return EvaluationDetail.fromValue(converter.toType(detail.getValue()), detail.getVariationIndex(), detail.getReason());
}

// TODO: when implementing hooks support in the future, verify prerequisite evaluations do not trigger the evaluation hooks
private EvaluationDetail<LDValue> variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason) {
LDContext context = clientContextImpl.getEvaluationContext();
Flag flag = contextDataManager.getNonDeletedFlag(key); // returns null for nonexistent *or* deleted flag
Expand All @@ -530,6 +531,13 @@ private EvaluationDetail<LDValue> variationDetailInternal(@NonNull String key, @
null, defaultValue, false, null);
result = EvaluationDetail.fromValue(defaultValue, EvaluationDetail.NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND));
} else {
if (flag.getPrerequisites() != null) {
// recurse on prerequisites to emulate prereq evaluations occurring with desirable side effects such as events for prereqs
for (String prereqKey : flag.getPrerequisites()) {
variationDetailInternal(prereqKey, LDValue.ofNull(), false, false);
}
}

LDValue value = flag.getValue();
int variation = flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation();
if (value.isNull()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ Flag createFlag(int version, LDContext context) {
EvaluationReason reason = targetedVariation == null ? EvaluationReason.fallthrough() :
EvaluationReason.targetMatch();
return new Flag(key, value, version, null, variation,
false, false, null, reason);
false, false, null, reason, null);
}

private static int variationForBoolean(boolean value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import java.io.IOException;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -568,24 +569,23 @@ public void refreshDataSourceWhileInBackgroundWithBackgroundPollingDisabled() {
@Test
public void notifyListenersWhenStatusChanges() throws Exception {
createTestManager(false, false, makeSuccessfulDataSourceFactory());

awaitStartUp();

LDStatusListener mockListener = mock(LDStatusListener.class);
// expected initial connection
// expected initial connection mode
mockListener.onConnectionModeChanged(anyObject(ConnectionInformation.class));
// expected second connection after identify
// expected second connection mode after identify
mockListener.onConnectionModeChanged(anyObject(ConnectionInformation.class));
expectLastCall();
replayAll();

AwaitableCallback<Void> identifyListenersCalled = new AwaitableCallback<>();
CountDownLatch latch = new CountDownLatch(2);
connectivityManager.registerStatusListener(mockListener);
connectivityManager.registerStatusListener(new LDStatusListener() {
@Override
public void onConnectionModeChanged(ConnectionInformation connectionInformation) {
// since the callback system is on another thread, need to use awaitable callback
identifyListenersCalled.onSuccess(null);
latch.countDown();
}

@Override
Expand All @@ -597,7 +597,7 @@ public void onInternalFailure(LDFailure ldFailure) {
LDContext context2 = LDContext.create("context2");
contextDataManager.switchToContext(context2);
connectivityManager.switchToContext(context2, new AwaitableCallback<>());
identifyListenersCalled.await();
latch.await(500, TimeUnit.MILLISECONDS);

verifyAll();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static com.launchdarkly.sdk.android.AssertHelpers.assertJsonEqual;
import static org.hamcrest.CoreMatchers.hasItems;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
Expand Down Expand Up @@ -54,14 +55,15 @@ public void toJson() {
.trackEvents(true)
.trackReason(true)
.debugEventsUntilDate(1000L)
.prerequisites(new String[]{"flagA", "flagB"})
.build();
Flag flag2 = new FlagBuilder("flag2").version(200).value(false).build();
EnvironmentData data = new DataSetBuilder().add(flag1).add(flag2).build();
String json = data.toJson();

String expectedJson = "{" +
"\"flag1\":{\"key\":\"flag1\",\"version\":100,\"flagVersion\":222,\"value\":true," +
"\"variation\":1,\"reason\":{\"kind\":\"OFF\"},\"trackEvents\":true," +
"\"variation\":1,\"prerequisites\":[\"flagA\",\"flagB\"],\"reason\":{\"kind\":\"OFF\"},\"trackEvents\":true," +
"\"trackReason\":true,\"debugEventsUntilDate\":1000}," +
"\"flag2\":{\"key\":\"flag2\",\"version\":200,\"value\":false}" +
"}";
Expand All @@ -72,7 +74,7 @@ public void toJson() {
public void fromJson() throws Exception {
String json = "{" +
"\"flag1\":{\"key\":\"flag1\",\"version\":100,\"flagVersion\":222,\"value\":true," +
"\"variation\":1,\"reason\":{\"kind\":\"OFF\"},\"trackEvents\":true," +
"\"variation\":1,\"prerequisites\":[\"flagA\",\"flagB\"],\"reason\":{\"kind\":\"OFF\"},\"trackEvents\":true," +
"\"trackReason\":true,\"debugEventsUntilDate\":1000}," +
"\"flag2\":{\"key\":\"flag2\",\"version\":200,\"value\":false}" +
"}";
Expand All @@ -87,6 +89,7 @@ public void fromJson() throws Exception {
assertEquals(Integer.valueOf(222), flag1.getFlagVersion());
assertEquals(LDValue.of(true), flag1.getValue());
assertEquals(Integer.valueOf(1), flag1.getVariation());
assertArrayEquals(new String[]{"flagA", "flagB"}, flag1.getPrerequisites());
assertTrue(flag1.isTrackEvents());
assertTrue(flag1.isTrackReason());
assertEquals(Long.valueOf(1000), flag1.getDebugEventsUntilDate());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance;

import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
Expand All @@ -20,6 +21,7 @@
import java.util.List;
import java.util.Map;

import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
Expand Down Expand Up @@ -206,6 +208,23 @@ public void trackReasonDefaultWhenOmitted() {
assertFalse(r.isTrackReason());
}

@Test
public void prerequisitesIsSerialized() {
final Flag r = new FlagBuilder("flag").prerequisites(new String[]{"flagB", "flagC"}).build();
final JsonObject json = gson.toJsonTree(r).getAsJsonObject();
final JsonArray array = json.getAsJsonArray("prerequisites");
assertEquals(2, array.size());
assertEquals("flagB", array.get(0).getAsString());
assertEquals("flagC", array.get(1).getAsString());
}

@Test
public void prerequisitesIsDeserialized() {
final String jsonStr = "{\"version\": 99, \"prerequisites\": [\"flagA\",\"flagB\"]}";
final Flag r = gson.fromJson(jsonStr, Flag.class);
assertArrayEquals(new String[]{"flagA","flagB"}, r.getPrerequisites());
}

@Test
public void debugEventsUntilDateIsSerialized() {
final long date = 12345L;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public class PersistentDataStoreWrapperTest extends EasyMockSupport {
private static final String EXPECTED_INDEX_KEY = "index";
private static final String EXPECTED_GENERATED_CONTEXT_KEY_PREFIX = "anonKey_";
private static final Flag FLAG = new Flag("flagkey", LDValue.of(true), 1,
null, 0, false, false, null, null);
null, 0, false, false, null, null, null);

private final PersistentDataStore mockPersistentStore;
private final PersistentDataStoreWrapper wrapper;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public DataSetBuilder add(Flag flag) {

public DataSetBuilder add(String flagKey, int version, LDValue value, int variation) {
return add(new Flag(flagKey, value, version, null, variation,
false, false, null, null));
false, false, null, null, null));
}

public DataSetBuilder add(String flagKey, LDValue value, int variation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public final class FlagBuilder {
private boolean trackReason = false;
private Long debugEventsUntilDate = null;
private EvaluationReason reason = null;
private String[] prerequisites = null;

public FlagBuilder(@NonNull String key) {
this.key = key;
Expand Down Expand Up @@ -66,7 +67,12 @@ public FlagBuilder reason(EvaluationReason reason) {
return this;
}

public FlagBuilder prerequisites(String[] prerequisites) {
this.prerequisites = prerequisites;
return this;
}

public Flag build() {
return new Flag(key, value, version, flagVersion, variation, trackEvents, trackReason, debugEventsUntilDate, reason);
return new Flag(key, value, version, flagVersion, variation, trackEvents, trackReason, debugEventsUntilDate, reason, prerequisites);
}
}

0 comments on commit 8d59b96

Please sign in to comment.