From e8e15ab4b46f26781ac37e0c52a0594fa401fa2a Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 16 Oct 2024 13:29:58 -0500 Subject: [PATCH 1/3] feat: Adds support for client-side prerequisite events --- .../com/launchdarkly/sdktest/TestService.java | 3 +- .../sdk/android/LDClientEventTest.java | 42 +++++++++++++++++++ .../launchdarkly/sdk/android/DataModel.java | 19 +++++++-- .../sdk/android/EnvironmentData.java | 2 +- .../launchdarkly/sdk/android/LDClient.java | 7 ++++ .../sdk/android/integrations/TestData.java | 2 +- .../sdk/android/EnvironmentDataTest.java | 7 +++- .../launchdarkly/sdk/android/FlagTest.java | 19 +++++++++ .../PersistentDataStoreWrapperTest.java | 2 +- .../sdk/android/DataSetBuilder.java | 2 +- .../launchdarkly/sdk/android/FlagBuilder.java | 8 +++- 11 files changed, 102 insertions(+), 11 deletions(-) diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java index 8287d0c8..ab27b379 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -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() diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java index 92e38dd3..97aaf420 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java @@ -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()) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataModel.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataModel.java index ca915618..2573bf4f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataModel.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataModel.java @@ -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( @@ -51,6 +52,7 @@ private Flag( boolean trackEvents, boolean trackReason, Long debugEventsUntilDate, + String[] prerequisites, boolean deleted ) { this.key = key; @@ -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; } @@ -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, @@ -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); } /** @@ -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() { @@ -122,6 +127,7 @@ Integer getVariation() { return variation; } + @Nullable EvaluationReason getReason() { return reason; } @@ -132,6 +138,7 @@ boolean isTrackEvents() { boolean isTrackReason() { return trackReason != null && trackReason.booleanValue(); } + @Nullable Long getDebugEventsUntilDate() { return debugEventsUntilDate; } @@ -140,6 +147,11 @@ int getVersionForEvents() { return flagVersion == null ? version : flagVersion.intValue(); } + @Nullable + String[] getPrerequisites() { + return prerequisites; + } + boolean isDeleted() { return deleted != null && deleted.booleanValue(); } @@ -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; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EnvironmentData.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EnvironmentData.java index 9c030e39..e177796e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EnvironmentData.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/EnvironmentData.java @@ -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); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 116cf5a7..6398a2e8 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -530,6 +530,13 @@ private EvaluationDetail 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()) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/TestData.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/TestData.java index 389da4ea..56eb8b0f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/TestData.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/TestData.java @@ -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) { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EnvironmentDataTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EnvironmentDataTest.java index d4d72c64..de393fae 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EnvironmentDataTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EnvironmentDataTest.java @@ -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; @@ -54,6 +55,7 @@ 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(); @@ -61,7 +63,7 @@ public void 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}" + "}"; @@ -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}" + "}"; @@ -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()); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagTest.java index a51c648f..36360754 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagTest.java @@ -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; @@ -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; @@ -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; diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapperTest.java index bb635961..1706bd0c 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapperTest.java @@ -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; diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/DataSetBuilder.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/DataSetBuilder.java index 4541593b..baa5ccac 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/DataSetBuilder.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/DataSetBuilder.java @@ -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) { diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/FlagBuilder.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/FlagBuilder.java index e5d2ec0f..2ef423ba 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/FlagBuilder.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/FlagBuilder.java @@ -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; @@ -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); } } From 982e2e54256f077f1c9e4827cd5a0153c6ca2bcf Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 16 Oct 2024 15:17:37 -0500 Subject: [PATCH 2/3] fixing flaky test --- .../sdk/android/ConnectivityManagerTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 0ca39dca..290a90d1 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -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; @@ -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 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 @@ -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(); } From 83889f5bc15c2943eb0f85a3dbe2e57a45d42e57 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 18 Oct 2024 09:29:11 -0500 Subject: [PATCH 3/3] added todo for future hooks dev --- .../src/main/java/com/launchdarkly/sdk/android/LDClient.java | 1 + 1 file changed, 1 insertion(+) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 6398a2e8..0129032e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -518,6 +518,7 @@ private EvaluationDetail convertDetailType(EvaluationDetail 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 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