diff --git a/contract-tests/build.gradle b/contract-tests/build.gradle index 6699750e..cbc145c9 100644 --- a/contract-tests/build.gradle +++ b/contract-tests/build.gradle @@ -28,6 +28,7 @@ dependencies { // https://mvnrepository.com/artifact/org.nanohttpd/nanohttpd implementation("org.nanohttpd:nanohttpd:2.3.1") implementation("com.google.code.gson:gson:2.8.9") + implementation("com.squareup.okhttp3:okhttp:4.9.2") implementation(project(":launchdarkly-android-client-sdk")) // Comment the previous line and uncomment this one to depend on the published artifact: //implementation("com.launchdarkly:launchdarkly-android-client-sdk:3.1.5") diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/HookCallbackService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/HookCallbackService.java new file mode 100644 index 00000000..96128f03 --- /dev/null +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/HookCallbackService.java @@ -0,0 +1,43 @@ +package com.launchdarkly.sdktest; + +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.net.URI; + +public class HookCallbackService { + private final URI serviceUri; + + public HookCallbackService(URI serviceUri) { + this.serviceUri = serviceUri; + } + + public void post(Object params) { + RequestBody body = RequestBody.create( + TestService.gson.toJson(params == null ? "{}" : params), + MediaType.parse("application/json") + ); + Request request = new Request.Builder().url(serviceUri.toString()) + .method("POST", body) + .build(); + try (Response response = TestService.client.newCall(request).execute()) { + assertOk(response); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void assertOk(Response response) { + if (!response.isSuccessful()) { + String body = ""; + if (response.body() != null) { + try { + body = ": " + response.body().string(); + } catch (Exception e) {} + } + throw new RuntimeException("HTTP error " + response.code() + " from callback to " + serviceUri + body); + } + } +} diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java index f67b6589..f66cb4aa 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java @@ -1,10 +1,15 @@ package com.launchdarkly.sdktest; import com.google.gson.annotations.SerializedName; +import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; +import java.net.URI; +import java.util.List; import java.util.Map; /** @@ -33,6 +38,7 @@ public static class SdkConfigParams { SdkConfigTagParams tags; SdkConfigClientSideParams clientSide; SdkConfigServiceEndpointParams serviceEndpoints; + SdkConfigHookParams hooks; } public static class SdkConfigStreamParams { @@ -74,6 +80,28 @@ public static class SdkConfigClientSideParams { boolean includeEnvironmentAttributes; } + public static class SdkConfigHookParams { + List hooks; + } + + public static class HookConfig { + String name; + URI callbackUri; + HookData data; + HookErrors errors; + } + + public static class HookData { + Map beforeEvaluation; + Map afterEvaluation; + } + + public static class HookErrors { + String beforeEvaluation; + String afterEvaluation; + String afterTrack; + } + public static class CommandParams { String command; EvaluateFlagParams evaluate; @@ -107,6 +135,18 @@ public static class EvaluateAllFlagsResponse { Map state; } + public static class EvaluationSeriesCallbackParams { + EvaluationSeriesContext evaluationSeriesContext; + Map evaluationSeriesData; + EvaluationDetail evaluationDetail; + String stage; + } + + public static class TrackSeriesCallbackParams { + TrackSeriesContext trackSeriesContext; + String stage; + } + public static class IdentifyEventParams { LDContext context; } diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java index f5c28cff..87459246 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -15,6 +15,7 @@ import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; @@ -31,12 +32,17 @@ import com.launchdarkly.sdktest.Representations.EvaluateAllFlagsResponse; import com.launchdarkly.sdktest.Representations.EvaluateFlagParams; import com.launchdarkly.sdktest.Representations.EvaluateFlagResponse; +import com.launchdarkly.sdktest.Representations.HookConfig; +import com.launchdarkly.sdktest.Representations.HookData; +import com.launchdarkly.sdktest.Representations.HookErrors; import com.launchdarkly.sdktest.Representations.IdentifyEventParams; import com.launchdarkly.sdktest.Representations.SdkConfigParams; import android.app.Application; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.Map; @@ -336,9 +342,34 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, endpoints.events(params.serviceEndpoints.events); } } - builder.serviceEndpoints(endpoints); + if (params.hooks != null && params.hooks.hooks != null) { + List hookList = new ArrayList<>(); + for (HookConfig hookConfig : params.hooks.hooks) { + HookCallbackService callbackService = new HookCallbackService(hookConfig.callbackUri); + + HookData data = new HookData(); + data.beforeEvaluation = hookConfig.data != null ? hookConfig.data.beforeEvaluation : null; + data.afterEvaluation = hookConfig.data != null ? hookConfig.data.afterEvaluation : null; + + HookErrors errors = new HookErrors(); + errors.beforeEvaluation = hookConfig.errors != null ? hookConfig.errors.beforeEvaluation : null; + errors.afterEvaluation = hookConfig.errors != null ? hookConfig.errors.afterEvaluation : null; + errors.afterTrack = hookConfig.errors != null ? hookConfig.errors.afterTrack : null; + + TestHook testHook = new TestHook( + hookConfig.name, + callbackService, + data, + errors + ); + + hookList.add(testHook); + } + builder.hooks(Components.hooks().setHooks(hookList)); + } + return builder.build(); } diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java new file mode 100644 index 00000000..8eafd442 --- /dev/null +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java @@ -0,0 +1,85 @@ +package com.launchdarkly.sdktest; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; + +import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; +import com.launchdarkly.sdktest.Representations.EvaluationSeriesCallbackParams; +import com.launchdarkly.sdktest.Representations.TrackSeriesCallbackParams; +import com.launchdarkly.sdktest.Representations.HookData; +import com.launchdarkly.sdktest.Representations.HookErrors; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class TestHook extends Hook { + private final HookCallbackService callbackService; + private final HookData hookData; + private final HookErrors hookErrors; + + public TestHook(String name, HookCallbackService callbackService, HookData data, HookErrors errors) { + super(name); + this.callbackService = callbackService; + this.hookData = data; + this.hookErrors = errors; + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map data) { + if (hookErrors.beforeEvaluation != null) { + throw new RuntimeException(hookErrors.beforeEvaluation); + } + + EvaluationSeriesCallbackParams params = new EvaluationSeriesCallbackParams(); + params.evaluationSeriesContext = seriesContext; + params.evaluationSeriesData = data; + params.stage = "beforeEvaluation"; + + callbackService.post(params); + + Map newData = new HashMap<>(data); + if (hookData.beforeEvaluation != null) { + newData.putAll(hookData.beforeEvaluation); + } + + return Collections.unmodifiableMap(newData); + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map data, EvaluationDetail evaluationDetail) { + if (hookErrors.afterEvaluation != null) { + throw new RuntimeException(hookErrors.afterEvaluation); + } + + EvaluationSeriesCallbackParams params = new EvaluationSeriesCallbackParams(); + params.evaluationSeriesContext = seriesContext; + params.evaluationSeriesData = data; + params.evaluationDetail = evaluationDetail; + params.stage = "afterEvaluation"; + + callbackService.post(params); + + Map newData = new HashMap<>(); + if (hookData.afterEvaluation != null) { + newData.putAll(hookData.afterEvaluation); + } + + return Collections.unmodifiableMap(newData); + } + + @Override + public void afterTrack(TrackSeriesContext seriesContext) { + if (hookErrors.afterTrack != null) { + throw new RuntimeException(hookErrors.afterTrack); + } + + TrackSeriesCallbackParams params = new TrackSeriesCallbackParams(); + params.trackSeriesContext = seriesContext; + params.stage = "afterTrack"; + + callbackService.post(params); + } +} 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 c356d55c..43ccc8f0 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -23,6 +23,7 @@ import java.util.regex.Pattern; import fi.iki.elonen.NanoHTTPD; +import okhttp3.OkHttpClient; public class TestService extends NanoHTTPD { private static final int PORT = 8001; @@ -36,13 +37,17 @@ public class TestService extends NanoHTTPD { "auto-env-attributes", "inline-context-all", "anonymous-redaction", - "client-prereq-events" + "client-prereq-events", + "evaluation-hooks", + "track-hooks" }; private static final String MIME_JSON = "application/json"; static final Gson gson = new GsonBuilder() .registerTypeAdapterFactory(LDGson.typeAdapters()) .create(); + static final OkHttpClient client = new OkHttpClient(); + private final Router router = new Router(); private final Application application; private final LDLogAdapter logAdapter; diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java new file mode 100644 index 00000000..36b24bae --- /dev/null +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientHooksTest.java @@ -0,0 +1,253 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; + +import android.app.Application; + +import androidx.test.core.app.ApplicationProvider; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; +import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class LDClientHooksTest { + + private static final String mobileKey = "test-mobile-key"; + private static final LDContext ldContext = LDContext.create("userKey"); + private Application application; + private MockHook testHook; + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + @Before + public void setUp() { + application = ApplicationProvider.getApplicationContext(); + testHook = new MockHook(); + } + + @Test + public void executesHooksRegisteredDuringConfiguration() throws Exception { + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { + ldClient.boolVariation("test-flag", false); + + EvaluationSeriesContext evaluationSeriesContext = new EvaluationSeriesContext("LDClient.boolVariation", "test-flag", ldContext, LDValue.of(false)); + EvaluationDetail evaluationDetail = EvaluationDetail.fromValue(LDValue.of(false), -1, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); + + assertEquals(1, testHook.beforeEvaluationCalls.size()); + assertEquals(evaluationSeriesContext, testHook.beforeEvaluationCalls.get(0).get("seriesContext")); + assertEquals(1, testHook.afterEvaluationCalls.size()); + assertEquals(evaluationSeriesContext, testHook.afterEvaluationCalls.get(0).get("seriesContext")); + assertEquals(evaluationDetail, testHook.afterEvaluationCalls.get(0).get("evaluationDetail")); + + LDContext newContext = LDContext.create("newUserKey"); + ldClient.identify(newContext).get(); + + IdentifySeriesContext identifySeriesContext = new IdentifySeriesContext(newContext, null); + IdentifySeriesResult identifySeriesResult = new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED); + + assertEquals(1, testHook.beforeIdentifyCalls.size()); + assertEquals(identifySeriesContext, testHook.beforeIdentifyCalls.get(0).get("seriesContext")); + assertEquals(1, testHook.afterIdentifyCalls.size()); + assertEquals(identifySeriesContext, testHook.afterIdentifyCalls.get(0).get("seriesContext")); + assertEquals(identifySeriesResult, testHook.afterIdentifyCalls.get(0).get("result")); + + ldClient.trackMetric("test-event", LDValue.buildObject().put("data", "test").build(), 123.45); + + TrackSeriesContext trackSeriesContext = new TrackSeriesContext("test-event", newContext, LDValue.buildObject().put("data", "test").build(), 123.45); + + assertEquals(1, testHook.afterTrackCalls.size()); + assertEquals(trackSeriesContext, testHook.afterTrackCalls.get(0).get("seriesContext")); + + logging.assertNoWarningsLogged(); + logging.assertNoErrorsLogged(); + } + } + + @Test + public void executesHooksAddedWithAddHooks() throws Exception { + MockHook addedHook = new MockHook(); + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(), ldContext, 1)) { + ldClient.addHook(addedHook); + + ldClient.boolVariation("test-flag", false); + + EvaluationSeriesContext evaluationSeriesContext = new EvaluationSeriesContext("LDClient.boolVariation", "test-flag", ldContext, LDValue.of(false)); + EvaluationDetail evaluationDetail = EvaluationDetail.fromValue(LDValue.of(false), -1, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); + + assertEquals(1, addedHook.beforeEvaluationCalls.size()); + assertEquals(evaluationSeriesContext, addedHook.beforeEvaluationCalls.get(0).get("seriesContext")); + assertEquals(1, addedHook.afterEvaluationCalls.size()); + assertEquals(evaluationSeriesContext, addedHook.afterEvaluationCalls.get(0).get("seriesContext")); + assertEquals(evaluationDetail, addedHook.afterEvaluationCalls.get(0).get("evaluationDetail")); + + LDContext newContext = LDContext.create("newUserKey"); + ldClient.identify(newContext).get(); + + IdentifySeriesContext identifySeriesContext = new IdentifySeriesContext(newContext, null); + IdentifySeriesResult identifySeriesResult = new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED); + + assertEquals(1, addedHook.beforeIdentifyCalls.size()); + assertEquals(identifySeriesContext, addedHook.beforeIdentifyCalls.get(0).get("seriesContext")); + assertEquals(1, addedHook.afterIdentifyCalls.size()); + assertEquals(identifySeriesContext, addedHook.afterIdentifyCalls.get(0).get("seriesContext")); + assertEquals(identifySeriesResult, addedHook.afterIdentifyCalls.get(0).get("result")); + + ldClient.trackMetric("test-event", LDValue.buildObject().put("data", "test").build(), 123.45); + + TrackSeriesContext trackSeriesContext = new TrackSeriesContext("test-event", newContext, LDValue.buildObject().put("data", "test").build(), 123.45); + + assertEquals(1, addedHook.afterTrackCalls.size()); + assertEquals(trackSeriesContext, addedHook.afterTrackCalls.get(0).get("seriesContext")); + + logging.assertNoWarningsLogged(); + logging.assertNoErrorsLogged(); + } + } + + @Test + public void executesBothInitialHooksAndHooksAddedWithAddHooks() throws Exception { + MockHook addedHook = new MockHook(); + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testHook)), ldContext, 1)) { + ldClient.addHook(addedHook); + + ldClient.boolVariation("test-flag", false); + + EvaluationSeriesContext evaluationSeriesContext = new EvaluationSeriesContext("LDClient.boolVariation", "test-flag", ldContext, LDValue.of(false)); + EvaluationDetail evaluationDetail = EvaluationDetail.fromValue(LDValue.of(false), -1, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); + + assertEquals(1, testHook.beforeEvaluationCalls.size()); + assertEquals(evaluationSeriesContext, testHook.beforeEvaluationCalls.get(0).get("seriesContext")); + assertEquals(1, testHook.afterEvaluationCalls.size()); + assertEquals(evaluationSeriesContext, testHook.afterEvaluationCalls.get(0).get("seriesContext")); + assertEquals(evaluationDetail, testHook.afterEvaluationCalls.get(0).get("evaluationDetail")); + + assertEquals(1, addedHook.beforeEvaluationCalls.size()); + assertEquals(evaluationSeriesContext, addedHook.beforeEvaluationCalls.get(0).get("seriesContext")); + assertEquals(1, addedHook.afterEvaluationCalls.size()); + assertEquals(evaluationSeriesContext, addedHook.afterEvaluationCalls.get(0).get("seriesContext")); + assertEquals(evaluationDetail, addedHook.afterEvaluationCalls.get(0).get("evaluationDetail")); + + LDContext newContext = LDContext.create("newUserKey"); + ldClient.identify(newContext).get(); + + IdentifySeriesContext identifySeriesContext = new IdentifySeriesContext(newContext, null); + IdentifySeriesResult identifySeriesResult = new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED); + + assertEquals(1, testHook.beforeIdentifyCalls.size()); + assertEquals(identifySeriesContext, testHook.beforeIdentifyCalls.get(0).get("seriesContext")); + assertEquals(1, testHook.afterIdentifyCalls.size()); + assertEquals(identifySeriesContext, testHook.afterIdentifyCalls.get(0).get("seriesContext")); + assertEquals(identifySeriesResult, testHook.afterIdentifyCalls.get(0).get("result")); + + assertEquals(1, addedHook.beforeIdentifyCalls.size()); + assertEquals(identifySeriesContext, addedHook.beforeIdentifyCalls.get(0).get("seriesContext")); + assertEquals(1, addedHook.afterIdentifyCalls.size()); + assertEquals(identifySeriesContext, addedHook.afterIdentifyCalls.get(0).get("seriesContext")); + assertEquals(identifySeriesResult, addedHook.afterIdentifyCalls.get(0).get("result")); + + ldClient.trackMetric("test-event", LDValue.buildObject().put("data", "test").build(), 123.45); + + TrackSeriesContext trackSeriesContext = new TrackSeriesContext("test-event", newContext, LDValue.buildObject().put("data", "test").build(), 123.45); + + assertEquals(1, testHook.afterTrackCalls.size()); + assertEquals(trackSeriesContext, testHook.afterTrackCalls.get(0).get("seriesContext")); + + assertEquals(1, addedHook.afterTrackCalls.size()); + assertEquals(trackSeriesContext, addedHook.afterTrackCalls.get(0).get("seriesContext")); + + logging.assertNoWarningsLogged(); + logging.assertNoErrorsLogged(); + } + } + + private LDConfig makeOfflineConfig() { + return makeOfflineConfig(null); + } + + private LDConfig makeOfflineConfig(List hooks) { + LDConfig.Builder builder = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled) + .mobileKey(mobileKey) + .offline(true) + .events(Components.noEvents()) + .logAdapter(logging.logAdapter); + + if (hooks != null) { + builder.hooks(Components.hooks().setHooks(hooks)); + } + + return builder.build(); + } + + private static class MockHook extends Hook { + public final List> beforeEvaluationCalls = new ArrayList<>(); + public final List> afterEvaluationCalls = new ArrayList<>(); + public final List> beforeIdentifyCalls = new ArrayList<>(); + public final List> afterIdentifyCalls = new ArrayList<>(); + public final List> afterTrackCalls = new ArrayList<>(); + + public MockHook() { + super("MockHook"); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + beforeEvaluationCalls.add(Map.of( + "seriesContext", seriesContext, + "seriesData", seriesData + )); + return Collections.unmodifiableMap(Collections.emptyMap()); + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, EvaluationDetail evaluationDetail) { + afterEvaluationCalls.add(Map.of( + "seriesContext", seriesContext, + "seriesData", seriesData, + "evaluationDetail", evaluationDetail + )); + return Collections.unmodifiableMap(Collections.emptyMap()); + } + + @Override + public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { + beforeIdentifyCalls.add(Map.of( + "seriesContext", seriesContext, + "seriesData", seriesData + )); + return Collections.unmodifiableMap(Collections.emptyMap()); + } + + @Override + public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, IdentifySeriesResult result) { + afterIdentifyCalls.add(Map.of( + "seriesContext", seriesContext, + "seriesData", seriesData, + "result", result + )); + return Collections.unmodifiableMap(Collections.emptyMap()); + } + + @Override + public void afterTrack(TrackSeriesContext seriesContext) { + afterTrackCalls.add(Map.of( + "seriesContext", seriesContext + )); + } + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java index eea95f08..5e4d56f6 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java @@ -2,6 +2,7 @@ import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; @@ -180,4 +181,23 @@ public static ServiceEndpointsBuilder serviceEndpoints() { public static StreamingDataSourceBuilder streamingDataSource() { return new ComponentsImpl.StreamingDataSourceBuilderImpl(); } + + /** + * Returns a builder for configuring hooks. + * Passing this to {@link LDConfig.Builder#hooks(com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder)}, + * after setting any desired hooks on the builder, applies this configuration to the SDK. + *

+     *     List hooks = myCreateHooksFunc();
+     *     LDConfig config = new LDConfig.Builder()
+     *         .hooks(
+     *             Components.hooks()
+     *                 .setHooks(hooks)
+     *         )
+     *         .build();
+     * 
+ * @return a {@link HooksConfigurationBuilder} that can be used for customization + */ + public static HooksConfigurationBuilder hooks() { + return new ComponentsImpl.HooksConfigurationBuilderImpl(); + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java index f5ee8026..69d1ff71 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; @@ -15,6 +16,7 @@ import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.DiagnosticDescription; import com.launchdarkly.sdk.android.subsystems.EventProcessor; +import com.launchdarkly.sdk.android.subsystems.HookConfiguration; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.internal.events.DefaultEventProcessor; import com.launchdarkly.sdk.internal.events.DefaultEventSender; @@ -350,6 +352,19 @@ public LDValue describeConfiguration(ClientContext clientContext) { } } + static final class HooksConfigurationBuilderImpl extends HooksConfigurationBuilder { + public static HooksConfigurationBuilderImpl fromHooksConfiguration(HookConfiguration hooksConfiguration) { + HooksConfigurationBuilderImpl builder = new HooksConfigurationBuilderImpl(); + builder.setHooks(hooksConfiguration.getHooks()); + return builder; + } + + @Override + public HookConfiguration build() { + return new HookConfiguration(hooks); + } + } + // Marker interface for data source implementations that will require a FeatureFetcher interface DataSourceRequiresFeatureFetcher {} } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java new file mode 100644 index 00000000..aa5f7674 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -0,0 +1,132 @@ +package com.launchdarkly.sdk.android; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; +import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class HookRunner { + @FunctionalInterface + public interface EvaluationMethod { + EvaluationDetail evaluate(); + } + + @FunctionalInterface + public interface AfterIdentifyMethod { + void invoke(IdentifySeriesResult result); + } + + private static final String UNKNOWN_HOOK_NAME = "unknown hook"; + + private final LDLogger logger; + private final List hooks = new ArrayList<>(); + + public HookRunner(LDLogger logger, List initialHooks) { + this.logger = logger; + this.hooks.addAll(initialHooks); + } + + private String getHookName(Hook hook) { + try { + String name = hook.getMetadata().getName(); + return (name == null || name.isEmpty()) ? UNKNOWN_HOOK_NAME : name; + } catch (Exception e) { + logger.error("Exception thrown getting metadata for hook. Unable to get hook name."); + return UNKNOWN_HOOK_NAME; + } + } + + public void addHook(Hook hook) { + hooks.add(hook); + } + + public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, EvaluationMethod evalMethod) { + if (hooks.isEmpty()) { + return evalMethod.evaluate(); + } + + List> seriesDataList = new ArrayList<>(hooks.size()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + for (int i = 0; i < hooks.size(); i++) { + Hook currentHook = hooks.get(i); + try { + Map seriesData = currentHook.beforeEvaluation(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); + seriesDataList.add(Collections.unmodifiableMap(seriesData)); + } catch (Exception e) { + seriesDataList.add(Collections.unmodifiableMap(Collections.emptyMap())); + logger.error("During evaluation of flag \"{}\". Stage \"beforeEvaluation\" of hook \"{}\" reported error: {}", key, getHookName(currentHook), e.toString()); + } + } + + EvaluationDetail result = evalMethod.evaluate(); + + // Invoke hooks in reverse order and give them back the series data they gave us. + for (int i = hooks.size() - 1; i >= 0; i--) { + Hook currentHook = hooks.get(i); + try { + currentHook.afterEvaluation(seriesContext, seriesDataList.get(i), result); + } catch (Exception e) { + logger.error("During evaluation of flag \"{}\". Stage \"afterEvaluation\" of hook \"{}\" reported error: {}", key, getHookName(currentHook), e.toString()); + } + } + + return result; + } + + public AfterIdentifyMethod identify(LDContext context, Integer timeout) { + if (hooks.isEmpty()) { + return (IdentifySeriesResult result) -> {}; + } + + List> seriesDataList = new ArrayList<>(hooks.size()); + IdentifySeriesContext seriesContext = new IdentifySeriesContext(context, timeout); + for (int i = 0; i < hooks.size(); i++) { + Hook currentHook = hooks.get(i); + try { + Map seriesData = currentHook.beforeIdentify(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); + seriesDataList.add(Collections.unmodifiableMap(seriesData)); + } catch (Exception e) { + seriesDataList.add(Collections.unmodifiableMap(Collections.emptyMap())); + logger.error("During identify with context \"{}\". Stage \"beforeIdentify\" of hook \"{}\" reported error: {}", context.getKey(), getHookName(currentHook), e.toString()); + } + } + + return (IdentifySeriesResult result) -> { + // Invoke hooks in reverse order and give them back the series data they gave us. + for (int i = hooks.size() - 1; i >= 0; i--) { + Hook currentHook = hooks.get(i); + try { + currentHook.afterIdentify(seriesContext, seriesDataList.get(i), result); + } catch (Exception e) { + logger.error("During identify with context \"{}\". Stage \"afterIdentify\" of hook \"{}\" reported error: {}", context.getKey(), getHookName(currentHook), e.toString()); + } + } + }; + } + + public void afterTrack(String key, LDContext context, LDValue data, Double metricValue) { + if (hooks.isEmpty()) { + return; + } + + TrackSeriesContext seriesContext = new TrackSeriesContext(key, context, data, metricValue); + for (int i = hooks.size() - 1; i >= 0; i--) { + Hook currentHook = hooks.get(i); + try { + currentHook.afterTrack(seriesContext); + } catch (Exception e) { + logger.error("During tracking of event \"{}\". Stage \"afterTrack\" of hook \"{}\" reported error: {}", key, getHookName(currentHook), e.toString()); + } + } + } +} 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 60eb7028..debe679f 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 @@ -13,6 +13,8 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; +import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.DataModel.Flag; import com.launchdarkly.sdk.android.subsystems.EventProcessor; @@ -27,6 +29,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; /** @@ -60,6 +63,7 @@ public class LDClient implements LDClientInterface, Closeable { private final EventProcessor eventProcessor; private final ConnectivityManager connectivityManager; private final LDLogger logger; + private final HookRunner hookRunner; // If 15 seconds or more is passed as a timeout to init, we will log a warning. private static final int EXCESSIVE_INIT_WAIT_SECONDS = 15; @@ -362,6 +366,8 @@ protected LDClient( contextDataManager, environmentStore ); + + hookRunner = new HookRunner(logger, config.hooks.getHooks()); } @Override @@ -382,6 +388,7 @@ public void track(String eventName) { private void trackInternal(String eventName, LDValue data, Double metricValue) { eventProcessor.recordCustomEvent(clientContextImpl.getEvaluationContext(), eventName, data, metricValue); + hookRunner.afterTrack(eventName, clientContextImpl.getEvaluationContext(), data, metricValue); } @Override @@ -433,16 +440,25 @@ private Future identifyInstances(@NonNull LDContext context) { final LDAwaitFuture resultFuture = new LDAwaitFuture<>(); final Map instancesNow = getInstancesIfTheyIncludeThisClient(); final AtomicInteger identifyCounter = new AtomicInteger(instancesNow.size()); + final AtomicBoolean identifyErrorOccurred = new AtomicBoolean(false); + + // TODO: If timeout support for identify is added, pass the timeout value here instead of null + HookRunner.AfterIdentifyMethod afterIdentify = hookRunner.identify(context, null); + Callback completeWhenCounterZero = new Callback() { @Override public void onSuccess(Void result) { if (identifyCounter.decrementAndGet() == 0) { + afterIdentify.invoke(new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED)); resultFuture.set(null); } } @Override public void onError(Throwable e) { + if (identifyErrorOccurred.compareAndSet(false, true)) { + afterIdentify.invoke(new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.ERROR)); + } resultFuture.setException(e); } }; @@ -466,59 +482,130 @@ public Map allFlags() { @Override public boolean boolVariation(@NonNull String key, boolean defaultValue) { - return variationDetailInternal(key, LDValue.of(defaultValue), true, false).getValue().booleanValue(); + return hookRunner.withEvaluation( + "LDClient.boolVariation", + key, + clientContextImpl.getEvaluationContext(), + LDValue.of(defaultValue), + () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) + ).getValue().booleanValue(); } @Override public EvaluationDetail boolVariationDetail(@NonNull String key, boolean defaultValue) { - return convertDetailType(variationDetailInternal(key, LDValue.of(defaultValue), true, true), LDValue.Convert.Boolean); + return convertDetailType( + hookRunner.withEvaluation( + "LDClient.boolVariationDetail", + key, + clientContextImpl.getEvaluationContext(), + LDValue.of(defaultValue), + () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) + ), + LDValue.Convert.Boolean + ); } @Override public int intVariation(@NonNull String key, int defaultValue) { - return variationDetailInternal(key, LDValue.of(defaultValue), true, false).getValue().intValue(); + return hookRunner.withEvaluation( + "LDClient.intVariation", + key, + clientContextImpl.getEvaluationContext(), + LDValue.of(defaultValue), + () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) + ).getValue().intValue(); } @Override public EvaluationDetail intVariationDetail(@NonNull String key, int defaultValue) { - return convertDetailType(variationDetailInternal(key, LDValue.of(defaultValue), true, true), LDValue.Convert.Integer); + return convertDetailType( + hookRunner.withEvaluation( + "LDClient.intVariationDetail", + key, + clientContextImpl.getEvaluationContext(), + LDValue.of(defaultValue), + () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) + ), + LDValue.Convert.Integer + ); } @Override - public double doubleVariation(String flagKey, double defaultValue) { - return variationDetailInternal(flagKey, LDValue.of(defaultValue), true, false).getValue().doubleValue(); + public double doubleVariation(@NonNull String key, double defaultValue) { + return hookRunner.withEvaluation( + "LDClient.doubleVariation", + key, + clientContextImpl.getEvaluationContext(), + LDValue.of(defaultValue), + () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) + ).getValue().doubleValue(); } @Override - public EvaluationDetail doubleVariationDetail(String flagKey, double defaultValue) { - return convertDetailType(variationDetailInternal(flagKey, LDValue.of(defaultValue), true, true), LDValue.Convert.Double); + public EvaluationDetail doubleVariationDetail(@NonNull String key, double defaultValue) { + return convertDetailType( + hookRunner.withEvaluation( + "LDClient.doubleVariationDetail", + key, + clientContextImpl.getEvaluationContext(), + LDValue.of(defaultValue), + () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) + ), + LDValue.Convert.Double + ); } @Override public String stringVariation(@NonNull String key, String defaultValue) { - return variationDetailInternal(key, LDValue.of(defaultValue), true, false).getValue().stringValue(); + return hookRunner.withEvaluation( + "LDClient.stringVariation", + key, + clientContextImpl.getEvaluationContext(), + LDValue.of(defaultValue), + () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) + ).getValue().stringValue(); } @Override public EvaluationDetail stringVariationDetail(@NonNull String key, String defaultValue) { - return convertDetailType(variationDetailInternal(key, LDValue.of(defaultValue), true, true), LDValue.Convert.String); + return convertDetailType( + hookRunner.withEvaluation( + "LDClient.stringVariationDetail", + key, + clientContextImpl.getEvaluationContext(), + LDValue.of(defaultValue), + () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) + ), + LDValue.Convert.String + ); } @Override public LDValue jsonValueVariation(@NonNull String key, LDValue defaultValue) { - return variationDetailInternal(key, LDValue.normalize(defaultValue), false, false).getValue(); + return hookRunner.withEvaluation( + "LDClient.jsonValueVariation", + key, + clientContextImpl.getEvaluationContext(), + LDValue.normalize(defaultValue), + () -> variationDetailInternal(key, LDValue.normalize(defaultValue), false, false) + ).getValue(); } @Override public EvaluationDetail jsonValueVariationDetail(@NonNull String key, LDValue defaultValue) { - return variationDetailInternal(key, LDValue.normalize(defaultValue), false, true); + return hookRunner.withEvaluation( + "LDClient.jsonValueVariationDetail", + key, + clientContextImpl.getEvaluationContext(), + LDValue.normalize(defaultValue), + () -> variationDetailInternal(key, LDValue.normalize(defaultValue), false, true) + ); } private EvaluationDetail convertDetailType(EvaluationDetail detail, LDValue.Converter converter) { 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 @@ -710,4 +797,9 @@ static LDLogger getSharedLogger() { } return LDLogger.none(); } + + @Override + public void addHook(Hook hook) { + hookRunner.addHook(hook); + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClientInterface.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClientInterface.java index f2051934..b7818b68 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClientInterface.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClientInterface.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.Hook; import java.io.Closeable; import java.util.Map; @@ -391,4 +392,14 @@ public interface LDClientInterface extends Closeable { * @since 2.7.0 */ String getVersion(); + + /** + * Add a hook to the client. In order to register a hook before the client + * starts, please use the `hooks` method of {@link LDConfig.Builder}. + *

+ * Hooks provide entry points which allow for observation of SDK functions. + * + * @param hook The hook to add. + */ + void addHook(Hook hook); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index 807cc64a..44d88e8e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -8,12 +8,14 @@ import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; +import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.android.subsystems.ApplicationInfo; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.EventProcessor; +import com.launchdarkly.sdk.android.subsystems.HookConfiguration; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.PersistentDataStore; @@ -61,6 +63,7 @@ public class LDConfig { final ApplicationInfo applicationInfo; final ComponentConfigurer dataSource; final ComponentConfigurer events; + final HookConfiguration hooks; final ComponentConfigurer http; private final boolean diagnosticOptOut; @@ -79,6 +82,7 @@ public class LDConfig { ApplicationInfo applicationInfo, ComponentConfigurer dataSource, ComponentConfigurer events, + HookConfiguration hooks, ComponentConfigurer http, boolean offline, boolean disableBackgroundUpdating, @@ -95,6 +99,7 @@ public class LDConfig { this.applicationInfo = applicationInfo; this.dataSource = dataSource; this.events = events; + this.hooks = hooks; this.http = http; this.offline = offline; this.disableBackgroundUpdating = disableBackgroundUpdating; @@ -219,6 +224,7 @@ public enum AutoEnvAttributes { private ApplicationInfoBuilder applicationInfoBuilder = null; private ComponentConfigurer dataSource = null; private ComponentConfigurer events = null; + private HooksConfigurationBuilder hooksConfigurationBuilder = null; private ComponentConfigurer http = null; private int maxCachedContexts = DEFAULT_MAX_CACHED_CONTEXTS; @@ -407,6 +413,21 @@ public Builder events(ComponentConfigurer eventsConfigurer) { return this; } + /** + * Sets the SDK's hooks configuration, using a builder. This is normally a obtained from + *

+ * {@link Components#hooks()} ()}, which has methods for setting individual other hook + * related properties. + * + * @param hooksConfiguration the hooks configuration builder + * @return the main configuration builder + * @see Components#hooks() + */ + public Builder hooks(HooksConfigurationBuilder hooksConfiguration) { + this.hooksConfigurationBuilder = hooksConfiguration; + return this; + } + /** * Sets the SDK's networking configuration, using a configuration builder. This builder is * obtained from {@link Components#httpConfiguration()}, and has methods for setting individual @@ -682,6 +703,7 @@ public LDConfig build() { applicationInfo, this.dataSource == null ? Components.streamingDataSource() : this.dataSource, this.events == null ? Components.sendEvents() : this.events, + (this.hooksConfigurationBuilder == null ? Components.hooks() : this.hooksConfigurationBuilder).build(), this.http == null ? Components.httpConfiguration() : this.http, offline, disableBackgroundUpdating, diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java new file mode 100644 index 00000000..d2043c70 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationSeriesContext.java @@ -0,0 +1,65 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; + +import java.util.Map; +import java.util.Objects; + +/** + * Represents parameters associated with a feature flag evaluation. An instance of this class is provided to some + * stages of series of a {@link Hook} implementation. For example, see {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} + */ +public class EvaluationSeriesContext { + + /** + * The variation method that was used to invoke the evaluation. The stability of this string is not + * guaranteed and should not be used in conditional logic. + */ + public final String method; + + /** + * The key of the feature flag being evaluated. + */ + public final String flagKey; + + /** + * The context the evaluation was for. + */ + public final LDContext context; + + /** + * The user-provided default value for the evaluation. + */ + public final LDValue defaultValue; + + /** + * @param method the variation method that was used to invoke the evaluation. + * @param key the key of the feature flag being evaluated. + * @param context the context the evaluation was for. + * @param defaultValue the user-provided default value for the evaluation. + */ + public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue) { + this.flagKey = key; + this.context = context; + this.defaultValue = defaultValue; + this.method = method; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + EvaluationSeriesContext other = (EvaluationSeriesContext)obj; + return + Objects.equals(method, other.method) && + Objects.equals(flagKey, other.flagKey) && + Objects.equals(context, other.context) && + Objects.equals(defaultValue, other.defaultValue); + } + + @Override + public int hashCode() { + return Objects.hash(method, flagKey, context, defaultValue); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java new file mode 100644 index 00000000..a9b88747 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Hook.java @@ -0,0 +1,150 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; + +import java.util.Map; + +/** + * A Hook is a set of user-defined callbacks that are executed by the SDK at various points of interest. To create + * your own hook with customized logic, implement the {@link Hook} interface. + *

+ * Multiple hooks may be configured in the SDK. By default, the SDK will execute each hook's before + * stages in the order they were configured, and each hook's after stages in reverse order. (i.e. + * myHook1.beforeEvaluation, myHook2.beforeEvaluation, myHook2.afterEvaluation, myHook1.afterEvaluation) + */ +public abstract class Hook { + + private final HookMetadata metadata; + + /** + * @return the hooks metadata + */ + public HookMetadata getMetadata() { + return metadata; + } + + /** + * Creates an instance of {@link Hook} with the given name which will be put into its metadata. + * + * @param name a friendly name for the hooks + */ + public Hook(String name) { + metadata = new HookMetadata(name) {}; + } + + /** + * {@link #beforeEvaluation(EvaluationSeriesContext, Map)} is executed by the SDK at the start of the evaluation of + * a feature flag. It will not be executed as part of a call to + * {@link com.launchdarkly.sdk.android.LDClient#allFlags()}. + *

+ * To provide custom data to the series which will be given back to your {@link Hook} at the next stage of the + * series, return a map containing the custom data. You should initialize this map from the {@code seriesData}. + * + *

+     * {@code
+     * HashMap customData = new HashMap<>(seriesData);
+     * customData.put("foo", "bar");
+     * return Collections.unmodifiableMap(customData);
+     * }
+     * 
+ * + * @param seriesContext container of parameters associated with this evaluation + * @param seriesData immutable data from the previous stage in evaluation series. {@link #beforeEvaluation(EvaluationSeriesContext, Map)} + * is the first stage in this series, so this will be an immutable empty map. + * @return a map containing custom data that will be carried through to the next stage of the series + */ + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + // default implementation is no-op + return seriesData; + } + + /** + * {@link #afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)} is executed by the SDK at the after the + * evaluation of a feature flag. It will not be executed as part of a call to + * {@link com.launchdarkly.sdk.android.LDClient#allFlags()}. + *

+ * This is currently the last stage of the evaluation series in the {@link Hook}, but that may not be the case in + * the future. To ensure forward compatibility, return the {@code seriesData} unmodified. + * + *

+     * {@code
+     * String value = (String) seriesData.get("foo");
+     * doAThing(value);
+     * return seriesData;
+     * }
+     * 
+ * + * @param seriesContext container of parameters associated with this evaluation + * @param seriesData immutable data from the previous stage in evaluation series. {@link #beforeEvaluation(EvaluationSeriesContext, Map)} + * is the first stage in this series, so this will be an immutable empty map. + * @param evaluationDetail the result of the evaluation that took place before this hook was invoked + * @return a map containing custom data that will be carried through to the next stage of the series (if added in the future) + */ + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + // default implementation is no-op + return seriesData; + } + + /** + * {@link #beforeIdentify(IdentifySeriesContext, Map)} is called during the execution of the identify process before the operation + * completes, but after any context modifications are performed. + *

+ * To provide custom data to the series which will be given back to your {@link Hook} at the next stage of the + * series, return a map containing the custom data. You should initialize this map from the {@code seriesData}. + * + *

+     * {@code
+     * HashMap customData = new HashMap<>(seriesData);
+     * customData.put("foo", "bar");
+     * return Collections.unmodifiableMap(customData);
+     * }
+     * 
+ * + * @param seriesContext Contains information about the evaluation being performed. This is not + * mutable. + * @param seriesData A record associated with each stage of hook invocations. Each stage is called with + * the data of the previous stage for a series. The input record should not be modified. + * @return a map containing custom data that will be carried through to the next stage of the series + */ + public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { + return seriesData; + } + + /** + * {@link #afterIdentify(IdentifySeriesContext, Map, IdentifySeriesResult)} is called during the execution of the identify process, + * after the operation completes. + *

+ * This is currently the last stage of the identify series in the {@link Hook}, but that may not be the case in + * the future. To ensure forward compatibility, return the {@code seriesData} unmodified. + * + *

+     * {@code
+     * String value = (String) seriesData.get("foo");
+     * doAThing(value);
+     * return seriesData;
+     * }
+     * 
+ * + * @param seriesContext Contains information about the evaluation being performed. This is not + * mutable. + * @param seriesData A record associated with each stage of hook invocations. Each stage is called with + * the data of the previous stage for a series. The input record should not be modified. + * @param result The result of the identify operation. + * @return a map containing custom data that will be carried through to the next stage of the series (if added in the future) + */ + public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, IdentifySeriesResult result) { + return seriesData; + } + + /** + * {@link #afterTrack(TrackSeriesContext)} is called during the execution of the track process after the event + * has been enqueued. + * + * @param seriesContext Contains information about the track operation being performed. This is not mutable. + */ + public void afterTrack(TrackSeriesContext seriesContext) { + // default implementation is no-op + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookMetadata.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookMetadata.java new file mode 100644 index 00000000..600c95ac --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HookMetadata.java @@ -0,0 +1,20 @@ +package com.launchdarkly.sdk.android.integrations; + +/** + * Metadata about the {@link Hook} implementation. + */ +public abstract class HookMetadata { + + private final String name; + + public HookMetadata(String name) { + this.name = name; + } + + /** + * @return a friendly name for the {@link Hook} this {@link HookMetadata} belongs to. + */ + public String getName() { + return name; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java new file mode 100644 index 00000000..c8892969 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HooksConfigurationBuilder.java @@ -0,0 +1,52 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.Components; +import com.launchdarkly.sdk.android.subsystems.HookConfiguration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Contains methods for configuring the SDK's 'hooks'. + *

+ * If you want to add hooks, use {@link Components#hooks()}, configure accordingly, and pass it + * to {@link com.launchdarkly.sdk.android.LDConfig.Builder#hooks(HooksConfigurationBuilder)}. + * + *


+ *     List hooks = createSomeHooks();
+ *     LDConfig config = new LDConfig.Builder()
+ *         .hooks(
+ *             Components.hooks()
+ *                 .setHooks(hooks)
+ *         )
+ *         .build();
+ * 
+ *

+ * Note that this class is abstract; the actual implementation is created by calling {@link Components#hooks()}. + */ +public abstract class HooksConfigurationBuilder { + + /** + * The current set of hooks the builder has. + */ + protected List hooks = Collections.emptyList(); + + /** + * Adds the provided list of hooks to the configuration. Note that the order of hooks is important and controls + * the order in which they will be executed. See {@link Hook} for more details. + * + * @param hooks to be added to the configuration + * @return the builder + */ + public HooksConfigurationBuilder setHooks(List hooks) { + // copy to avoid list manipulations impacting the SDK + this.hooks = Collections.unmodifiableList(new ArrayList<>(hooks)); + return this; + } + + /** + * @return the hooks configuration + */ + abstract public HookConfiguration build(); +} \ No newline at end of file diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/IdentifySeriesContext.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/IdentifySeriesContext.java new file mode 100644 index 00000000..a3491425 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/IdentifySeriesContext.java @@ -0,0 +1,41 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.LDContext; + +import java.util.Objects; + +/** + * Represents parameters associated with calling identify. An instance of this class is provided to some + * stages of series of a {@link Hook} implementation. For example, see {@link Hook#afterTrack(TrackSeriesContext)} + */ +public class IdentifySeriesContext { + /** + * The context associated with the identify operation. + */ + public final LDContext context; + + /** + * The timeout, in seconds, associated with the identify operation. + */ + public final Integer timeout; + + public IdentifySeriesContext(LDContext context, Integer timeout) { + this.context = context; + this.timeout = timeout; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + IdentifySeriesContext other = (IdentifySeriesContext)obj; + return + Objects.equals(context, other.context) && + Objects.equals(timeout, other.timeout); + } + + @Override + public int hashCode() { + return Objects.hash(context, timeout); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/IdentifySeriesResult.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/IdentifySeriesResult.java new file mode 100644 index 00000000..b99ebc41 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/IdentifySeriesResult.java @@ -0,0 +1,44 @@ +package com.launchdarkly.sdk.android.integrations; + +import java.util.Objects; + +/** + * The result applies to a single identify operation. An operation may complete + * with an error and then later complete successfully. Only the first completion + * will be executed in the identify series. + *

+ * For example, a network issue may cause an identify to error since the SDK + * can't refresh its cached data from the cloud at that moment, but then later + * the when the network issue is resolved, the SDK will refresh cached data. + */ +public class IdentifySeriesResult { + /** + * The status an identify operation completed with. + *

+ * An example in which an error may occur is lack of network connectivity + * preventing the SDK from functioning. + */ + public enum IdentifySeriesStatus { + COMPLETED, + ERROR + } + + public final IdentifySeriesStatus status; + + public IdentifySeriesResult(IdentifySeriesStatus status) { + this.status = status; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + IdentifySeriesResult other = (IdentifySeriesResult)obj; + return status == other.status; + } + + @Override + public int hashCode() { + return Objects.hash(status); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/TrackSeriesContext.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/TrackSeriesContext.java new file mode 100644 index 00000000..83ca47da --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/TrackSeriesContext.java @@ -0,0 +1,62 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; + +import java.util.Objects; + +/** + * Represents parameters associated with tracking a custom event. An instance of this class is provided to some + * stages of series of a {@link Hook} implementation. For example, see {@link Hook#afterTrack(TrackSeriesContext)} + */ +public class TrackSeriesContext { + /** + * The key for the event being tracked. + */ + public final String key; + + /** + * The context associated with the track operation. + */ + public final LDContext context; + + /** + * The data associated with the track operation. + */ + public final LDValue data; + + /** + * The metric value associated with the track operation. + */ + public final Double metricValue; + + /** + * @param key the key for the event being tracked. + * @param context the context associated with the track operation. + * @param data the data associated with the track operation. + * @param metricValue the metric value associated with the track operation. + */ + public TrackSeriesContext(String key, LDContext context, LDValue data, Double metricValue) { + this.key = key; + this.context = context; + this.data = data; + this.metricValue = metricValue; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + TrackSeriesContext other = (TrackSeriesContext)obj; + return + Objects.equals(key, other.key) && + Objects.equals(context, other.context) && + Objects.equals(data, other.data) && + Objects.equals(metricValue, other.metricValue); + } + + @Override + public int hashCode() { + return Objects.hash(key, context, data, metricValue); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/HookConfiguration.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/HookConfiguration.java new file mode 100644 index 00000000..50d7f578 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/HookConfiguration.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.android.subsystems; + +import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; +import com.launchdarkly.sdk.android.integrations.Hook; + +import java.util.Collections; +import java.util.List; + +/** + * Encapsulates the SDK's 'hooks' configuration. + *

+ * Use {@link HooksConfigurationBuilder} to construct an instance. + */ +public class HookConfiguration { + + private final List hooks; + + /** + * @param hooks the list of {@link Hook} that will be registered. + */ + public HookConfiguration(List hooks) { + this.hooks = Collections.unmodifiableList(hooks); + } + + /** + * @return an immutable list of hooks + */ + public List getHooks() { + return hooks; + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java new file mode 100644 index 00000000..3824f753 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HookRunnerTest.java @@ -0,0 +1,491 @@ +package com.launchdarkly.sdk.android; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.integrations.HookMetadata; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; +import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; + +import org.easymock.EasyMockSupport; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class HookRunnerTest extends EasyMockSupport { + private HookRunner hookRunner; + private Hook testHook; + + private static class TestHookMetaData extends HookMetadata { + TestHookMetaData(String name) { + super(name); + } + } + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + @Before + public void setUp() { + testHook = mock(Hook.class); + hookRunner = new HookRunner(logging.logger, List.of(testHook)); + } + + @Test + public void executesHooksAndReturnsEvaluationResult() { + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + EvaluationDetail result = hookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertSame(evaluationResult, result); + logging.assertNothingLogged(); + } + + @Test + public void handlesErrorInEvaluationHooks() { + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + RuntimeException exception = new RuntimeException("Hook error"); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andThrow(exception); + expect(testHook.getMetadata()).andReturn(new TestHookMetaData("TestHook")); + expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + EvaluationDetail result = hookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertSame(evaluationResult, result); + logging.assertErrorLogged(String.format("During evaluation of flag \"%s\". Stage \"beforeEvaluation\" of hook \"TestHook\" reported error: %s", key, exception)); + } + + @Test + public void skipsEvaluationHookExecutionIfThereAreNoHooks() { + HookRunner emptyHookRunner = new HookRunner(logging.logger, Collections.emptyList()); + + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + EvaluationDetail result = emptyHookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + assertSame(evaluationResult, result); + logging.assertNothingLogged(); + } + + @Test + public void passesEvaluationSeriesDataFromBeforeToAfterHooks () { + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + Map seriesData = Map.of("key-1", "value-1", "key-2", false); + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andReturn(seriesData); + expect(testHook.afterEvaluation(seriesContext, seriesData, evaluationResult)).andReturn(seriesData); + replayAll(); + + EvaluationDetail result = hookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertSame(evaluationResult, result); + logging.assertNothingLogged(); + } + + @Test + public void executesEvaluationHookStagesInTheCorrectOrder() { + List beforeEvalOrder = new ArrayList<>(); + List afterEvalOrder = new ArrayList<>(); + Map seriesData = Collections.unmodifiableMap(Collections.emptyMap()); + + Hook hookA = mock(Hook.class); + expect(hookA.beforeEvaluation(anyObject(), anyObject())).andStubAnswer(() -> { beforeEvalOrder.add("a"); return seriesData; }); + expect(hookA.afterEvaluation(anyObject(), anyObject(), anyObject())).andStubAnswer(() -> { afterEvalOrder.add("a"); return seriesData; }); + + Hook hookB = mock(Hook.class); + expect(hookB.beforeEvaluation(anyObject(), anyObject())).andStubAnswer(() -> { beforeEvalOrder.add("b"); return seriesData; }); + expect(hookB.afterEvaluation(anyObject(), anyObject(), anyObject())).andStubAnswer(() -> { afterEvalOrder.add("b"); return seriesData; }); + + Hook hookC = mock(Hook.class); + expect(hookC.beforeEvaluation(anyObject(), anyObject())).andStubAnswer(() -> { beforeEvalOrder.add("c"); return seriesData; }); + expect(hookC.afterEvaluation(anyObject(), anyObject(), anyObject())).andStubAnswer(() -> { afterEvalOrder.add("c"); return seriesData; }); + + replayAll(); + + HookRunner runner = new HookRunner(logging.logger, List.of(hookA, hookB, hookC)); + + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + runner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertEquals(beforeEvalOrder, List.of("a", "b", "c")); + assertEquals(afterEvalOrder, List.of("c", "b", "a")); + } + + @Test + public void executesIdentifyHooks() { + LDContext context = LDContext.create("user-123"); + Integer timeout = 10; + + IdentifySeriesResult identifyResult = new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED); + IdentifySeriesContext seriesContext = new IdentifySeriesContext(context, timeout); + + expect(testHook.beforeIdentify(seriesContext, Collections.emptyMap())).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + expect(testHook.afterIdentify(seriesContext, Collections.emptyMap(), identifyResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + HookRunner.AfterIdentifyMethod afterIdentifyMethod = hookRunner.identify(context, timeout); + afterIdentifyMethod.invoke(identifyResult); + + verifyAll(); + logging.assertNothingLogged(); + } + + @Test + public void handlesErrorInIdentifyHooks() { + LDContext context = LDContext.create("user-123"); + Integer timeout = 10; + RuntimeException exception = new RuntimeException("Hook error"); + + IdentifySeriesResult identifyResult = new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.ERROR); + IdentifySeriesContext seriesContext = new IdentifySeriesContext(context, timeout); + + expect(testHook.beforeIdentify(seriesContext, Collections.emptyMap())).andThrow(exception); + expect(testHook.getMetadata()).andReturn(new TestHookMetaData("TestHook")); + expect(testHook.afterIdentify(seriesContext, Collections.emptyMap(), identifyResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + HookRunner.AfterIdentifyMethod afterIdentifyMethod = hookRunner.identify(context, timeout); + afterIdentifyMethod.invoke(identifyResult); + + verifyAll(); + logging.assertErrorLogged(String.format("During identify with context \"%s\". Stage \"beforeIdentify\" of hook \"TestHook\" reported error: %s", context.getKey(), exception)); + } + + @Test + public void passesIdentifySeriesDataFromBeforeToAfterHooks() { + LDContext context = LDContext.create("user-123"); + Integer timeout = 10; + + IdentifySeriesResult identifyResult = new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED); + IdentifySeriesContext seriesContext = new IdentifySeriesContext(context, timeout); + Map seriesData = Map.of("key-1", "value-1", "key-2", false); + + expect(testHook.beforeIdentify(seriesContext, Collections.emptyMap())).andReturn(seriesData); + expect(testHook.afterIdentify(seriesContext, seriesData, identifyResult)).andReturn(seriesData); + replayAll(); + + HookRunner.AfterIdentifyMethod afterIdentifyMethod = hookRunner.identify(context, timeout); + afterIdentifyMethod.invoke(identifyResult); + + verifyAll(); + logging.assertNothingLogged(); + } + + @Test + public void skipsIdentifyHookExecutionIfThereAreNoHooks() { + HookRunner emptyHookRunner = new HookRunner(logging.logger, Collections.emptyList()); + + LDContext context = LDContext.create("user-123"); + Integer timeout = 10; + + IdentifySeriesResult identifyResult = new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED); + + HookRunner.AfterIdentifyMethod afterIdentifyMethod = emptyHookRunner.identify(context, timeout); + afterIdentifyMethod.invoke(identifyResult); + + logging.assertNothingLogged(); + } + + @Test + public void executesIdentifyHookStagesInTheCorrectOrder() { + List beforeIdentifyOrder = new ArrayList<>(); + List afterIdentifyOrder = new ArrayList<>(); + Map seriesData = Collections.unmodifiableMap(Collections.emptyMap()); + + Hook hookA = mock(Hook.class); + expect(hookA.beforeIdentify(anyObject(), anyObject())).andStubAnswer(() -> { beforeIdentifyOrder.add("a"); return seriesData; }); + expect(hookA.afterIdentify(anyObject(), anyObject(), anyObject())).andStubAnswer(() -> { afterIdentifyOrder.add("a"); return seriesData; }); + + Hook hookB = mock(Hook.class); + expect(hookB.beforeIdentify(anyObject(), anyObject())).andStubAnswer(() -> { beforeIdentifyOrder.add("b"); return seriesData; }); + expect(hookB.afterIdentify(anyObject(), anyObject(), anyObject())).andStubAnswer(() -> { afterIdentifyOrder.add("b"); return seriesData; }); + + Hook hookC = mock(Hook.class); + expect(hookC.beforeIdentify(anyObject(), anyObject())).andStubAnswer(() -> { beforeIdentifyOrder.add("c"); return seriesData; }); + expect(hookC.afterIdentify(anyObject(), anyObject(), anyObject())).andStubAnswer(() -> { afterIdentifyOrder.add("c"); return seriesData; }); + + replayAll(); + + HookRunner runner = new HookRunner(logging.logger, List.of(hookA, hookB, hookC)); + + LDContext context = LDContext.create("user-123"); + Integer timeout = 10; + + IdentifySeriesResult identifyResult = new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED); + + HookRunner.AfterIdentifyMethod afterIdentifyMethod = runner.identify(context, timeout); + afterIdentifyMethod.invoke(identifyResult); + + verifyAll(); + assertEquals(beforeIdentifyOrder, List.of("a", "b", "c")); + assertEquals(afterIdentifyOrder, List.of("c", "b", "a")); + } + + @Test + public void executesAfterTrackHooks() { + LDContext context = LDContext.create("user-123"); + String key = "test-event"; + LDValue data = LDValue.buildObject().put("test", "data").build(); + Double metricValue = 123.45; + + TrackSeriesContext seriesContext = new TrackSeriesContext(key, context, data, metricValue); + + testHook.afterTrack(seriesContext); + expectLastCall().andVoid(); + replayAll(); + + hookRunner.afterTrack(key, context, data, metricValue); + + verifyAll(); + logging.assertNothingLogged(); + } + + @Test + public void handlesErrorInAfterTrackHooks() { + LDContext context = LDContext.create("user-123"); + String key = "test-event"; + LDValue data = LDValue.buildObject().put("test", "data").build(); + Double metricValue = 123.45; + RuntimeException exception = new RuntimeException("Hook error"); + + TrackSeriesContext seriesContext = new TrackSeriesContext(key, context, data, metricValue); + + expect(testHook.getMetadata()).andReturn(new TestHookMetaData("TestHook")); + testHook.afterTrack(seriesContext); + expectLastCall().andThrow(exception); + replayAll(); + + hookRunner.afterTrack(key, context, data, metricValue); + + verifyAll(); + logging.assertErrorLogged(String.format("During tracking of event \"%s\". Stage \"afterTrack\" of hook \"TestHook\" reported error: %s", key, exception)); + } + + @Test + public void skipsAfterTrackHookExecutionIfThereAreNoHooks() { + HookRunner emptyHookRunner = new HookRunner(logging.logger, Collections.emptyList()); + + LDContext context = LDContext.create("user-123"); + String key = "test-event"; + LDValue data = LDValue.buildObject().put("test", "data").build(); + Double metricValue = 123.45; + + emptyHookRunner.afterTrack(key, context, data, metricValue); + + logging.assertNothingLogged(); + } + + @Test + public void executesAfterTrackHookStagesInTheCorrectOrder() { + List afterTrackOrder = new ArrayList<>(); + + Hook hookA = mock(Hook.class); + hookA.afterTrack(anyObject()); + expectLastCall().andStubAnswer(() -> { afterTrackOrder.add("a"); return null; }); + + Hook hookB = mock(Hook.class); + hookB.afterTrack(anyObject()); + expectLastCall().andStubAnswer(() -> { afterTrackOrder.add("b"); return null; }); + + Hook hookC = mock(Hook.class); + hookC.afterTrack(anyObject()); + expectLastCall().andStubAnswer(() -> { afterTrackOrder.add("c"); return null; }); + + replayAll(); + + HookRunner runner = new HookRunner(logging.logger, List.of(hookA, hookB, hookC)); + + LDContext context = LDContext.create("user-123"); + String key = "test-event"; + LDValue data = LDValue.buildObject().put("test", "data").build(); + Double metricValue = 123.45; + + runner.afterTrack(key, context, data, metricValue); + + verifyAll(); + assertEquals(afterTrackOrder, List.of("c", "b", "a")); + } + + @Test public void usesAddedHookInFutureInvocations() { + Hook newHook = mock(Hook.class); + hookRunner.addHook(newHook); + + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + expect(newHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + expect(newHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + EvaluationDetail result = hookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertSame(evaluationResult, result); + logging.assertNothingLogged(); + } + + @Test + public void logsUnknownHookWhenGetMetadataThrows() { + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + RuntimeException exception = new RuntimeException("Hook error"); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andThrow(exception); + expect(testHook.getMetadata()).andThrow(new RuntimeException()); + expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + EvaluationDetail result = hookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertSame(evaluationResult, result); + logging.assertErrorLogged("Exception thrown getting metadata for hook. Unable to get hook name."); + logging.assertErrorLogged(String.format("During evaluation of flag \"%s\". Stage \"beforeEvaluation\" of hook \"unknown hook\" reported error: %s", key, exception)); + } + + @Test + public void logsUnknownHookWhenGetMetadataReturnsNull() { + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + RuntimeException exception = new RuntimeException("Hook error"); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andThrow(exception); + expect(testHook.getMetadata()).andReturn(null); + expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + EvaluationDetail result = hookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertSame(evaluationResult, result); + logging.assertErrorLogged("Exception thrown getting metadata for hook. Unable to get hook name."); + logging.assertErrorLogged(String.format("During evaluation of flag \"%s\". Stage \"beforeEvaluation\" of hook \"unknown hook\" reported error: %s", key, exception)); + } + + @Test + public void logsUnknownHookWhenGetMetadataReturnsEmptyName() { + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + RuntimeException exception = new RuntimeException("Hook error"); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andThrow(exception); + expect(testHook.getMetadata()).andReturn(new TestHookMetaData("")); + expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + EvaluationDetail result = hookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertSame(evaluationResult, result); + logging.assertErrorLogged(String.format("During evaluation of flag \"%s\". Stage \"beforeEvaluation\" of hook \"unknown hook\" reported error: %s", key, exception)); + } + + @Test + public void logsUnknownHookWhenGetMetadataReturnsNullName() { + String method = "testMethod"; + String key = "test-flag"; + LDContext context = LDContext.create("user-123"); + LDValue defaultValue = LDValue.of(false); + RuntimeException exception = new RuntimeException("Hook error"); + + EvaluationDetail evaluationResult = EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off()); + EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + HookRunner.EvaluationMethod evaluationMethod = () -> evaluationResult; + + expect(testHook.beforeEvaluation(seriesContext, Collections.emptyMap())).andThrow(exception); + expect(testHook.getMetadata()).andReturn(new TestHookMetaData(null)); + expect(testHook.afterEvaluation(seriesContext, Collections.emptyMap(), evaluationResult)).andReturn(Collections.unmodifiableMap(Collections.emptyMap())); + replayAll(); + + EvaluationDetail result = hookRunner.withEvaluation(method, key, context, defaultValue, evaluationMethod); + + verifyAll(); + assertSame(evaluationResult, result); + logging.assertErrorLogged(String.format("During evaluation of flag \"%s\". Stage \"beforeEvaluation\" of hook \"unknown hook\" reported error: %s", key, exception)); + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java index c0684882..0b74ef32 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java @@ -3,11 +3,16 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.easymock.EasyMock.createMock; + import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; +import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.android.subsystems.ClientContext; import org.junit.Rule; @@ -33,6 +38,8 @@ public void testBuilderDefaults() { assertNull(config.getMobileKey()); assertFalse(config.isEvaluationReasons()); assertFalse(config.getDiagnosticOptOut()); + + assertEquals(0, config.hooks.getHooks().size()); } @Test @@ -154,4 +161,16 @@ public void serviceEndpointsCustom() { assertEquals(URI.create("http://uri3"), config.serviceEndpoints.getEventsBaseUri()); } + + @Test + public void hooks() { + Hook mockHook = createMock(Hook.class); + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) + .hooks( + Components.hooks().setHooks(List.of(mockHook)) + ) + .build(); + assertEquals(1, config.hooks.getHooks().size()); + assertSame(mockHook, config.hooks.getHooks().get(0)); + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/HookConfigurationBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/HookConfigurationBuilderTest.java new file mode 100644 index 00000000..7d3634da --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/HookConfigurationBuilderTest.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.Components; +import com.launchdarkly.sdk.android.subsystems.HookConfiguration; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.easymock.EasyMock.createMock; + +import java.util.List; + +public class HookConfigurationBuilderTest { + @Test + public void emptyHooksAsDefault() { + HookConfiguration configuration = Components.hooks().build(); + assertEquals(0, configuration.getHooks().size()); + } + + @Test + public void canSetHooks() { + Hook hookA = createMock(Hook.class); + Hook hookB = createMock(Hook.class); + HookConfiguration configuration = Components.hooks().setHooks(List.of(hookA, hookB)).build(); + assertEquals(2, configuration.getHooks().size()); + assertSame(hookA, configuration.getHooks().get(0)); + assertSame(hookB, configuration.getHooks().get(1)); + } +} diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/LogCaptureRule.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/LogCaptureRule.java index 7a939dd1..bd90787a 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/LogCaptureRule.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/LogCaptureRule.java @@ -42,6 +42,14 @@ public void assertNothingLogged() { assertThat(logCapture.getMessages(), not(hasItems(anything()))); } + public void assertNoErrorsLogged() { + assertThat(logCapture.getMessageStrings(), not(hasItems(containsString("ERROR:")))); + } + + public void assertNoWarningsLogged() { + assertThat(logCapture.getMessageStrings(), not(hasItems(containsString("WARN:")))); + } + public void assertInfoLogged(String messageSubstring) { assertThat(logCapture.getMessageStrings(), hasItems(allOf(containsString("INFO:")),