diff --git a/example/README.md b/example/README.md new file mode 100644 index 00000000..dc1debc9 --- /dev/null +++ b/example/README.md @@ -0,0 +1,19 @@ +# LaunchDarkly Android SDK example app + +## Configuration + +The app reads its settings from `local.properties` in the repository root, which is not checked in. +Create it if it does not exist and add: + +```properties +launchdarkly.mobileKey=your-mobile-key +launchdarkly.environment=production +``` + +| Property | Default | Description | +| --- | --- | --- | +| `launchdarkly.mobileKey` | none | Mobile key for the environment to connect to. The app refuses to initialize without it. | +| `launchdarkly.environment` | `production` | `staging` points the streaming, polling, and events endpoints at the `ld-stg.launchdarkly.com` hosts. Any other value uses the SDK defaults. | + +These become `BuildConfig` fields, so change them and rebuild for them to take effect. The mobile key +has to match the environment: a production key against staging fails to authorize. diff --git a/example/build.gradle b/example/build.gradle index 797ab8d8..f5286b24 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -1,9 +1,19 @@ +import java.util.Properties + plugins { id("com.android.application") // make sure this line comes *after* you apply the Android plugin id("com.getkeepsafe.dexcount") } +// local.properties is not checked in, so it is where machine-specific settings such as your mobile +// key belong. See example/README.md for the keys this app reads. +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { localProperties.load(it) } +} + android { namespace "com.launchdarkly.example" compileSdk = 34 @@ -14,6 +24,15 @@ android { targetSdk = 34 versionCode = 1 versionName = "1.0" + + buildConfigField("String", "MOBILE_KEY", + "\"${localProperties.getProperty('launchdarkly.mobileKey', '')}\"") + buildConfigField("String", "LD_ENVIRONMENT", + "\"${localProperties.getProperty('launchdarkly.environment', 'production')}\"") + } + + buildFeatures { + buildConfig = true } buildTypes { diff --git a/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java new file mode 100644 index 00000000..4b3eef67 --- /dev/null +++ b/example/src/main/java/com/launchdarkly/example/ExposureCountingHook.java @@ -0,0 +1,64 @@ +package com.launchdarkly.example; + +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 java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Counts the evaluation series stages it observes, so the example can show what exposure + * deduplication does. Deduplication is configured at registration by wrapping this hook in a + * {@link com.launchdarkly.sdk.android.integrations.DedupingHook}, the same way a customer would + * wrap any other hook. + *

+ * Deduplication skips the whole series, so both counts stay equal and both stop climbing while + * repeated evaluations resolve to the same result. + */ +class ExposureCountingHook extends Hook { + private final String label; + private final Runnable onStage; + private final AtomicInteger befores = new AtomicInteger(); + private final AtomicInteger afters = new AtomicInteger(); + + /** + * @param label a name for this hook, shown in the app's dedupe status + * @param onStage run after each stage so the app can refresh its display + */ + ExposureCountingHook(String label, Runnable onStage) { + super(label); + this.label = label; + this.onStage = onStage; + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + befores.incrementAndGet(); + onStage.run(); + return seriesData; + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + afters.incrementAndGet(); + onStage.run(); + return seriesData; + } + + /** + * @param windowMillis the dedupe window this hook was registered with, for the status line + * @return a line describing this hook's window and how many evaluations have reached it + */ + String status(int windowMillis) { + return String.format(Locale.US, "%s (%d ms): %d (before %d / after %d)", + label, + windowMillis, + afters.get(), + befores.get(), + afters.get()); + } +} diff --git a/example/src/main/java/com/launchdarkly/example/MainActivity.java b/example/src/main/java/com/launchdarkly/example/MainActivity.java index 684f85b3..9b604324 100644 --- a/example/src/main/java/com/launchdarkly/example/MainActivity.java +++ b/example/src/main/java/com/launchdarkly/example/MainActivity.java @@ -22,6 +22,7 @@ import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.LDFailure; import com.launchdarkly.sdk.android.LDStatusListener; +import com.launchdarkly.sdk.android.integrations.DedupingHook; import java.util.Date; import java.util.Locale; @@ -29,15 +30,50 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; import timber.log.Timber; public class MainActivity extends AppCompatActivity { + // Two hooks with different windows, to show that each one is deduplicated on its own. + private static final int FAST_DEDUPE_WINDOW_MILLIS = 5_000; + private static final int SLOW_DEDUPE_WINDOW_MILLIS = 10_000; + + // The staging hosts mirror the production ones in StandardEndpoints under the ld-stg domain. + private static final String STAGING_DOMAIN = "ld-stg.launchdarkly.com"; + + private static final String DEFAULT_USER_KEY = "user key"; + private LDClient ldClient; private LDStatusListener ldStatusListener; private LDAllFlagsListener allFlagsListener; + private final ExposureCountingHook fastHook = + new ExposureCountingHook("fast", this::updateDedupeStatus); + private final ExposureCountingHook slowHook = + new ExposureCountingHook("slow", this::updateDedupeStatus); + private final AtomicInteger evaluationsRequested = new AtomicInteger(); + + private static boolean isStaging() { + return "staging".equalsIgnoreCase(BuildConfig.LD_ENVIRONMENT); + } + + private void updateDedupeStatus() { + if (Looper.myLooper() != MainActivity.this.getMainLooper()) { + new Handler(MainActivity.this.getMainLooper()).post(this::updateDedupeStatus); + return; + } + + String result = String.format(Locale.US, + "Environment: %s\nEvaluations requested: %d\n%s\n%s", + isStaging() ? "staging" : "production", + evaluationsRequested.get(), + fastHook.status(FAST_DEDUPE_WINDOW_MILLIS), + slowHook.status(SLOW_DEDUPE_WINDOW_MILLIS)); + ((TextView) MainActivity.this.findViewById(R.id.dedupe_status)).setText(result); + } + private void updateStatusString(final ConnectionInformation connectionInformation) { if (Looper.myLooper() != MainActivity.this.getMainLooper()) { new Handler(MainActivity.this.getMainLooper()).post(() -> updateStatusString(connectionInformation)); @@ -68,16 +104,41 @@ public void onCreate(Bundle savedInstanceState) { setupIdentifyButton(); setupOfflineSwitch(); setupListeners(); + updateDedupeStatus(); - LDConfig ldConfig = new LDConfig.Builder(AutoEnvAttributes.Enabled) - .mobileKey("MOBILE_KEY") + if (BuildConfig.MOBILE_KEY.isEmpty()) { + String message = "Set launchdarkly.mobileKey in local.properties and rebuild."; + Timber.e(message); + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + return; + } + + LDConfig.Builder configBuilder = new LDConfig.Builder(AutoEnvAttributes.Enabled) + .mobileKey(BuildConfig.MOBILE_KEY) .http( Components.httpConfiguration().useReport(false) // change useReport to `true` if the request is to be REPORT'ed instead of GET'ed ) - .build(); + .hooks( + // Same shape a customer uses for any hook: wrap it at registration. Each + // wrapper has its own window, so neither suppresses the other. + Components.hooks() + .addHook(new DedupingHook(fastHook, FAST_DEDUPE_WINDOW_MILLIS)) + .addHook(new DedupingHook(slowHook, SLOW_DEDUPE_WINDOW_MILLIS)) + ); - LDContext context = LDContext.builder("user key") + if (isStaging()) { + configBuilder.serviceEndpoints( + Components.serviceEndpoints() + .streaming("https://clientstream." + STAGING_DOMAIN) + .polling("https://clientsdk." + STAGING_DOMAIN) + .events("https://mobile." + STAGING_DOMAIN) + ); + } + + LDConfig ldConfig = configBuilder.build(); + + LDContext context = LDContext.builder(DEFAULT_USER_KEY) .set("email", "fake@example.com") .build(); @@ -158,9 +219,17 @@ private void setupIdentifyButton() { Button identify = findViewById(R.id.identify_button); identify.setOnClickListener(v -> { Timber.i("identify onClick"); - String userKey = ((EditText) MainActivity.this.findViewById(R.id.userKey_editText)).getText().toString(); + String typedKey = ((EditText) MainActivity.this.findViewById(R.id.userKey_editText)) + .getText().toString().trim(); + // An empty key builds an invalid context, which identify rejects before it resets the + // hooks' dedupe caches. Fall back to the key the client started with, so identifying to + // an unchanged context still demonstrates that the reset happens either way. + final String userKey = typedKey.isEmpty() ? DEFAULT_USER_KEY : typedKey; final LDContext updatedContext = LDContext.create(userKey); - MainActivity.this.doSafeClientAction(() -> ldClient.identify(updatedContext)); + MainActivity.this.doSafeClientAction(() -> { + ldClient.identify(updatedContext); + }); + MainActivity.this.updateDedupeStatus(); }); } @@ -182,6 +251,7 @@ private void setupEval() { evalButton.setOnClickListener(v -> { Timber.i("eval onClick"); final String flagKey = ((EditText) MainActivity.this.findViewById(R.id.feature_flag_key)).getText().toString(); + evaluationsRequested.incrementAndGet(); String type = spinner.getSelectedItem().toString(); final String result; @@ -194,10 +264,13 @@ private void setupEval() { ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); MainActivity.this.doSafeClientAction(() -> { ldClient.registerFeatureFlagListener(flagKey, flagKey1 -> { + evaluationsRequested.incrementAndGet(); ((TextView) MainActivity.this.findViewById(R.id.result_textView)) .setText(ldClient.stringVariation(flagKey1, "default")); + MainActivity.this.updateDedupeStatus(); }); }); + MainActivity.this.updateDedupeStatus(); return; case "Boolean": result = MainActivity.this.doSafeClientGet(() -> String.valueOf(ldClient.boolVariation(flagKey, false))); @@ -219,6 +292,7 @@ private void setupEval() { logResult = result == null ? "no result" : result; Timber.i(logResult); ((TextView) MainActivity.this.findViewById(R.id.result_textView)).setText(result); + MainActivity.this.updateDedupeStatus(); }); } diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index 168abad2..8657d607 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -84,13 +84,25 @@ android:id="@+id/result_textView" android:layout_width="wrap_content" android:layout_height="0dp" - android:layout_above="@+id/connection_status" + android:layout_above="@+id/dedupe_status" android:layout_below="@+id/space" android:layout_alignParentStart="true" android:layout_alignParentLeft="true" android:layout_alignParentEnd="true" android:layout_alignParentRight="true" /> + + dataSourceConfig = clientContext -> + MockComponents.successfulDataSource(clientContext, data, + ConnectionInformation.ConnectionMode.POLLING, null, null); + LDConfig config = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled) + .mobileKey(mobileKey) + .dataSource(dataSourceConfig) + .events(Components.noEvents()) + .hooks(Components.hooks().setHooks(List.of(hook))) + .logAdapter(logging.logAdapter) + .persistentDataStore(new InMemoryPersistentDataStore()) + .build(); + return LDClient.init(application, config, ldContext, 5); + } + private LDConfig makeOfflineConfig() { return makeOfflineConfig(null); } private LDConfig makeOfflineConfig(List hooks) { + return makeOfflineConfigBuilder(hooks).build(); + } + + private LDConfig.Builder makeOfflineConfigBuilder(List hooks) { LDConfig.Builder builder = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled) .mobileKey(mobileKey) .offline(true) @@ -193,7 +339,7 @@ private LDConfig makeOfflineConfig(List hooks) { builder.hooks(Components.hooks().setHooks(hooks)); } - return builder.build(); + return builder; } private static class MockHook extends Hook { 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 index b05ae07e..6415aff0 100644 --- 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 @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKeySupplier; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; @@ -31,9 +32,26 @@ public interface AfterIdentifyMethod { private final LDLogger logger; private final List hooks = new ArrayList<>(); + // Handed to every evaluation series context, which calls it only if a hook asks what the + // evaluation's result identifies. Dedupe is the only thing that asks, and only a hook that has + // been wrapped in a DedupingHook dedupes, so an application without one never pays for this. + private final EvaluationExposureKeySupplier exposureKeySupplier; + + /** + * Builds a runner whose evaluations describe no result, so a hook that asks what one identifies + * is told nothing and treats every evaluation as its own. + */ public HookRunner(LDLogger logger, List initialHooks) { + this(logger, initialHooks, null); + } + + public HookRunner(LDLogger logger, List initialHooks, + EvaluationExposureKeySupplier exposureKeySupplier) { this.logger = logger; - this.hooks.addAll(initialHooks); + this.exposureKeySupplier = exposureKeySupplier; + for (Hook hook : initialHooks) { + addHook(hook); + } } private String getHookName(Hook hook) { @@ -50,13 +68,28 @@ public void addHook(Hook hook) { hooks.add(hook); } + /** + * Runs the evaluation series around an evaluation of a flag the caller has not read, so a hook + * that asks what the evaluation's result identifies is told nothing. + */ public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, EvaluationMethod evalMethod) { + return withEvaluation(method, key, context, defaultValue, null, evalMethod); + } + + /** + * Runs the evaluation series around an evaluation, against the caller's own read of the flag. + * + * @param flag the read the evaluation derives its result from, so that a hook is told about the + * result the evaluation returns rather than about a later read of the store + */ + public EvaluationDetail withEvaluation(String method, String key, LDContext context, LDValue defaultValue, DataModel.Flag flag, EvaluationMethod evalMethod) { if (hooks.isEmpty()) { return evalMethod.evaluate(); } List> seriesDataList = new ArrayList<>(hooks.size()); - EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue); + EvaluationSeriesContext seriesContext = + new EvaluationSeriesContext(method, key, context, defaultValue, exposureKeySupplier, flag); for (int i = 0; i < hooks.size(); i++) { Hook currentHook = hooks.get(i); try { 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 c74e75cb..2338b821 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 @@ -3,6 +3,7 @@ import android.app.Application; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.launchdarkly.logging.LDLogger; @@ -15,6 +16,8 @@ import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; import com.launchdarkly.sdk.android.integrations.Plugin; @@ -439,7 +442,7 @@ protected LDClient( environmentStore ); - hookRunner = new HookRunner(logger, config.hooks.getHooks()); + hookRunner = new HookRunner(logger, config.hooks.getHooks(), this::exposureKey); } @Override @@ -553,123 +556,92 @@ public Map allFlags() { @Override public boolean boolVariation(@NonNull String key, boolean defaultValue) { - return hookRunner.withEvaluation( - "LDClient.boolVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) - ).getValue().booleanValue(); + return evaluateWithHooks("LDClient.boolVariation", key, LDValue.of(defaultValue), true, false) + .getValue().booleanValue(); } @Override public EvaluationDetail boolVariationDetail(@NonNull String key, boolean defaultValue) { return convertDetailType( - hookRunner.withEvaluation( - "LDClient.boolVariationDetail", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) - ), + evaluateWithHooks("LDClient.boolVariationDetail", key, LDValue.of(defaultValue), true, true), LDValue.Convert.Boolean ); } @Override public int intVariation(@NonNull String key, int defaultValue) { - return hookRunner.withEvaluation( - "LDClient.intVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) - ).getValue().intValue(); + return evaluateWithHooks("LDClient.intVariation", key, LDValue.of(defaultValue), true, false) + .getValue().intValue(); } @Override public EvaluationDetail intVariationDetail(@NonNull String key, int defaultValue) { return convertDetailType( - hookRunner.withEvaluation( - "LDClient.intVariationDetail", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) - ), + evaluateWithHooks("LDClient.intVariationDetail", key, LDValue.of(defaultValue), true, true), LDValue.Convert.Integer ); } @Override 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(); + return evaluateWithHooks("LDClient.doubleVariation", key, LDValue.of(defaultValue), true, false) + .getValue().doubleValue(); } @Override 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) - ), + evaluateWithHooks("LDClient.doubleVariationDetail", key, LDValue.of(defaultValue), true, true), LDValue.Convert.Double ); } @Override public String stringVariation(@NonNull String key, String defaultValue) { - return hookRunner.withEvaluation( - "LDClient.stringVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, false) - ).getValue().stringValue(); + return evaluateWithHooks("LDClient.stringVariation", key, LDValue.of(defaultValue), true, false) + .getValue().stringValue(); } @Override public EvaluationDetail stringVariationDetail(@NonNull String key, String defaultValue) { return convertDetailType( - hookRunner.withEvaluation( - "LDClient.stringVariationDetail", - key, - clientContextImpl.getEvaluationContext(), - LDValue.of(defaultValue), - () -> variationDetailInternal(key, LDValue.of(defaultValue), true, true) - ), + evaluateWithHooks("LDClient.stringVariationDetail", key, LDValue.of(defaultValue), true, true), LDValue.Convert.String ); } @Override public LDValue jsonValueVariation(@NonNull String key, LDValue defaultValue) { - return hookRunner.withEvaluation( - "LDClient.jsonValueVariation", - key, - clientContextImpl.getEvaluationContext(), - LDValue.normalize(defaultValue), - () -> variationDetailInternal(key, LDValue.normalize(defaultValue), false, false) - ).getValue(); + return evaluateWithHooks("LDClient.jsonValueVariation", key, LDValue.normalize(defaultValue), false, false) + .getValue(); } @Override public EvaluationDetail jsonValueVariationDetail(@NonNull String key, LDValue defaultValue) { + return evaluateWithHooks("LDClient.jsonValueVariationDetail", key, LDValue.normalize(defaultValue), false, true); + } + + /** + * Runs an evaluation, and the hooks around it, against one read of the flag and of the evaluation + * context. + *

+ * Both are read here rather than left to the evaluation so that the result a hook is told the + * evaluation is about to return is the result it does return, and so that the events for that + * result are attributed to the same context. Reading only the flag here and letting the evaluation + * re-read the context would leave an identify landing in between attributing the prior context's + * flag to the new context in events. + */ + private EvaluationDetail evaluateWithHooks(String method, String key, LDValue defaultValue, + boolean checkType, boolean needsReason) { + LDContext context = clientContextImpl.getEvaluationContext(); + Flag flag = contextDataManager.getNonDeletedFlag(key); // returns null for nonexistent *or* deleted flag return hookRunner.withEvaluation( - "LDClient.jsonValueVariationDetail", + method, key, - clientContextImpl.getEvaluationContext(), - LDValue.normalize(defaultValue), - () -> variationDetailInternal(key, LDValue.normalize(defaultValue), false, true) + context, + defaultValue, + flag, + () -> variationDetailInternal(key, defaultValue, checkType, needsReason, null, flag, context) ); } @@ -677,13 +649,7 @@ private EvaluationDetail convertDetailType(EvaluationDetail deta return EvaluationDetail.fromValue(converter.toType(detail.getValue()), detail.getVariationIndex(), detail.getReason()); } - private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason) { - return variationDetailInternal(key, defaultValue, checkType, needsReason, null); - } - - private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason, Set visited) { - LDContext context = clientContextImpl.getEvaluationContext(); - Flag flag = contextDataManager.getNonDeletedFlag(key); // returns null for nonexistent *or* deleted flag + private EvaluationDetail variationDetailInternal(@NonNull String key, @NonNull LDValue defaultValue, boolean checkType, boolean needsReason, Set visited, @Nullable Flag flag, @NonNull LDContext context) { EvaluationDetail result; if (flag == null) { @@ -711,7 +677,9 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ // value and reason (below) are unchanged. continue; } - variationDetailInternal(prereqKey, LDValue.ofNull(), false, false, visited); + // The prerequisite is evaluated as part of the same call, so it is attributed to the same context. + variationDetailInternal(prereqKey, LDValue.ofNull(), false, false, visited, + contextDataManager.getNonDeletedFlag(prereqKey), context); } } finally { visited.remove(key); @@ -748,6 +716,34 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ return result; } + /** + * Identifies the evaluation a hook is about to be told about, so that a deduper can recognize a + * repeat of it. + *

+ * This describes the flag rather than the evaluation result because the decision is made before + * the series opens: hooks pair their stages, so the observability plugin starts a span in + * {@code beforeEvaluation} and ends it in {@code afterEvaluation}, and suppressing only the + * after stage would leave that span open until something else closed it. It is given the + * evaluation's own read of the flag, so it identifies the result that evaluation goes on to + * return. + */ + private EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext, @Nullable Flag flag) { + String flagKey = seriesContext.flagKey; + int variation = flag == null || flag.getVariation() == null + ? EvaluationDetail.NO_VARIATION : flag.getVariation(); + int flagVersion = flag == null ? EventProcessor.NO_VERSION : flag.getVersionForEvents(); + // The value the evaluation returns, which is the default value when there is no value to + // return: a flag the SDK has no data for, and a flag whose data carries no value, both fall + // back to it, as they do on the event the evaluation records. A value the calling method + // rejects as the wrong type also falls back to the default, but is not recognized here, + // because the type that method wanted is not part of the series context. + LDValue flagValue = flag == null ? LDValue.ofNull() : flag.getValue(); + LDValue value = flagValue.isNull() ? seriesContext.defaultValue : flagValue; + + return new EvaluationExposureKey(clientContextImpl.getEnvironmentName(), flagKey, value, + variation, flagVersion, seriesContext.context.getFullyQualifiedKey()); + } + /** * Closes the client. This should only be called at the end of a client's lifecycle. * diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java new file mode 100644 index 00000000..60adc19e --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java @@ -0,0 +1,206 @@ +package com.launchdarkly.sdk.android.integrations; + +import android.os.SystemClock; + +import androidx.annotation.VisibleForTesting; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Wraps a hook so that repeated evaluations resolving to the same result do not reach it again within + * a time window. + *

+ * The wrapped hook is told about a flag when its result changes, and at most once per window while the + * result stays the same. This is useful for reducing the telemetry volume produced by frequent + * re-evaluations, for example a flag that is read on every redraw of a view. Deduplication is opt-in: + * a hook that is registered unwrapped observes every evaluation. + * + *


+ *     Components.hooks()
+ *         .addHook(new MetricsHook())                                  // observes every evaluation
+ *         .addHook(new DedupingHook(new ObservabilityHook()))           // default window
+ *         .addHook(new DedupingHook(new TelemetryHook(), 60_000))
+ *         .addHook(new DedupingHook(new ExperimentHook(), myCustomDeduper))
+ * 
+ *

+ * Two evaluations resolve to the same result when they agree on everything + * {@link EvaluationExposureKey} describes. Pass your own {@link EvaluationExposureDeduper} subclass to + * decide that differently. + *

+ * An evaluation the SDK has no flag data for resolves to the default value, and is the same result as + * another that does. Evaluations made before the client has flags are of that kind, so the wrapped + * hook is told about one of them and then told about the flag again as soon as its data arrives. + *

+ * A suppressed evaluation reaches neither + * {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} nor + * {@link Hook#afterEvaluation(EvaluationSeriesContext, Map, EvaluationDetail)}, because hooks pair + * their stages. The identify and track stages are always forwarded. Analytics events are unaffected: + * feature, debug, and summary events are still recorded for every evaluation, so the evaluation counts + * LaunchDarkly reports for your flags do not change. + *

+ * What the wrapped hook has been told about is cleared by + * {@link com.launchdarkly.sdk.android.LDClient#identify(com.launchdarkly.sdk.LDContext)}, so the first + * evaluation of each flag after an identify always reaches it. + *

+ * Give each hook its own instance unless you intend hooks to share a window: the first hook to be told + * about an evaluation starts the window that suppresses the rest. + *

+ * Wrap outermost when you stack decorators. Suppressing an evaluation means returning series data that + * says so in place of what the stage was given, so a decorator outside this one does not get back what + * it stored in its own before stage. A decorator inside this one is unaffected, since a suppressed + * evaluation never reaches it. + */ +public final class DedupingHook extends Hook { + + /** + * Reads the clock a window is measured against. Exists so that tests can control it; the SDK has + * one implementation. + */ + interface Clock { + long elapsedMillis(); + } + + /** + * Counts from boot rather than from the epoch, so that correcting the device clock cannot stretch + * a window: were this wall clock time, a correction that moved the clock backwards would leave + * every recorded time in the future and suppress those flags until real time caught up. It also + * advances while the device sleeps, unlike {@code System.nanoTime()}, so a window is an interval + * of real time rather than of awake time. + */ + private static final Clock ELAPSED_REALTIME = SystemClock::elapsedRealtime; + + // Namespaced because it travels in series data that the wrapped hook may also write to. + private static final String SUPPRESSED = "com.launchdarkly.sdk.android.DedupingHook.suppressed"; + + private final Hook delegate; + private final EvaluationExposureDeduper deduper; + private final Clock clock; + + // Returned in place of the wrapped hook's series data when an evaluation is suppressed, and + // recognized by identity so that stacked instances each recognize only their own suppressions. + private final Map suppressedSeriesData = + Collections.singletonMap(SUPPRESSED, this); + + /** + * Wraps a hook with a window of {@link EvaluationExposureDeduper#DEFAULT_WINDOW_MILLIS}. + * + * @param delegate the hook to wrap + */ + public DedupingHook(Hook delegate) { + this(delegate, new EvaluationExposureDeduper()); + } + + /** + * @param delegate the hook to wrap + * @param windowMillis the dedupe window in milliseconds; zero or negative forwards every + * evaluation + */ + public DedupingHook(Hook delegate, int windowMillis) { + this(delegate, new EvaluationExposureDeduper(windowMillis)); + } + + /** + * @param delegate the hook to wrap + * @param deduper decides which evaluations reach the wrapped hook + */ + public DedupingHook(Hook delegate, EvaluationExposureDeduper deduper) { + this(delegate, deduper, ELAPSED_REALTIME); + } + + @VisibleForTesting + DedupingHook(Hook delegate, EvaluationExposureDeduper deduper, Clock clock) { + super(nameOf(delegate)); + this.delegate = Objects.requireNonNull(delegate, "a deduping hook must wrap a hook"); + this.deduper = Objects.requireNonNull(deduper, "a deduping hook must have a deduper"); + this.clock = clock; + } + + /** + * @return the wrapped hook's metadata, so that the SDK names the hook a stage belongs to + */ + @Override + public HookMetadata getMetadata() { + return delegate.getMetadata(); + } + + /** + * Forwards the evaluation unless the wrapped hook has just been told about the same result. + *

+ * The decision is made here, before the evaluation runs, so that a suppressed evaluation reaches + * neither stage of the wrapped hook. An evaluation whose result the SDK did not describe, which is + * to say a series context built by something other than the SDK, is always forwarded. + * + * @param seriesContext container of parameters associated with this evaluation + * @param seriesData immutable data from the previous stage in the evaluation series + * @return the wrapped hook's series data, or data marking the series as suppressed + */ + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + EvaluationExposureKey key = seriesContext.getEvaluationExposureKey(); + if (key != null && !deduper.shouldRecord(key, clock.elapsedMillis())) { + return suppressedSeriesData; + } + return delegate.beforeEvaluation(seriesContext, seriesData); + } + + /** + * Forwards the result unless this instance suppressed the series in its before stage. + * + * @param seriesContext container of parameters associated with this evaluation + * @param seriesData the data returned by this hook's before stage + * @param evaluationDetail the result of the evaluation + * @return the wrapped hook's series data, unchanged if the series was suppressed + */ + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + if (seriesData != null && seriesData.get(SUPPRESSED) == this) { + return seriesData; + } + return delegate.afterEvaluation(seriesContext, seriesData, evaluationDetail); + } + + /** + * Forgets which results the wrapped hook has been told about, then forwards the stage. + *

+ * Evaluations observed before an identify describe an earlier point in the application's + * lifecycle, so they are reported again afterwards. This happens even when the context is + * unchanged, so that identify is a reliable way for an application to mark a new phase of a + * session. + * + * @param seriesContext container of parameters associated with this identify + * @param seriesData immutable data from the previous stage in the identify series + * @return the wrapped hook's series data + */ + @Override + public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { + deduper.reset(); + return delegate.beforeIdentify(seriesContext, seriesData); + } + + @Override + public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, + IdentifySeriesResult result) { + return delegate.afterIdentify(seriesContext, seriesData, result); + } + + @Override + public void afterTrack(TrackSeriesContext seriesContext) { + delegate.afterTrack(seriesContext); + } + + // Static because it runs in the super() call, before this instance exists. Tolerates a hook whose + // metadata throws, which the SDK reports rather than propagates. + private static String nameOf(Hook delegate) { + try { + return delegate == null ? null : delegate.getMetadata().getName(); + } catch (Exception e) { + return null; + } + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java new file mode 100644 index 00000000..9ba10305 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureDeduper.java @@ -0,0 +1,173 @@ +package com.launchdarkly.sdk.android.integrations; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Decides whether a hook should be told about an evaluation, so that repeated evaluations resolving + * to the same result do not invoke the hook again within a time window. + *

+ * Deduplication is opt-in per hook: a hook is told about every evaluation until you wrap it in a + * {@link DedupingHook}, which is what consults a deduper. + * + *


+ *     Components.hooks()
+ *         .addHook(new MetricsHook())                        // told about every evaluation
+ *         .addHook(new DedupingHook(new ObservabilityHook())) // default window
+ *         .addHook(new DedupingHook(new TelemetryHook(), 30_000))
+ *         .addHook(new DedupingHook(new ExperimentHook(), myCustomDeduper))
+ * 
+ *

+ * This class is the SDK's implementation: it remembers the result each flag last reported, and tells + * the hook about the flag again as soon as that result changes, or once the window elapses while it + * stays the same. Tracking one result per flag rather than every result seen keeps a flag that flips + * back and forth from hiding the flips, and holds one record per flag the application evaluates, so + * the window is the only thing there is to configure. Subclass this to implement a different policy; + * only {@link #shouldRecord(EvaluationExposureKey, long)} and {@link #reset()} are called by + * {@link DedupingHook}. + *

+ * A deduper is consulted once per evaluation, before the series opens, so a suppressed evaluation + * invokes neither {@code beforeEvaluation} nor {@code afterEvaluation}. Implementations must be + * thread-safe, because evaluations may be made from any thread. Give each hook its own instance + * unless you intend hooks to share a window: the first hook to be told about an exposure starts the + * window that suppresses the rest. + */ +public class EvaluationExposureDeduper { + /** + * The dedupe window used by a deduper built without a window of its own: 10 minutes, in + * milliseconds. + */ + public static final int DEFAULT_WINDOW_MILLIS = 600_000; + + private final long windowMillis; + + // Last result reported for each flag, per environment. Entries stay until reset(). + // Accessed only from the synchronized methods on this instance. + private final Map lastReported = new HashMap<>(); + + /** + * Creates a deduper with a window of {@link #DEFAULT_WINDOW_MILLIS}. + */ + public EvaluationExposureDeduper() { + this(DEFAULT_WINDOW_MILLIS); + } + + /** + * @param windowMillis the dedupe window in milliseconds; zero or negative disables + * deduplication, so every evaluation reaches the hook + */ + public EvaluationExposureDeduper(int windowMillis) { + this.windowMillis = windowMillis; + } + + /** + * Returns whether the hook should be told about the evaluation identified by the given key, and + * if so starts a new dedupe window for the flag. + *

+ * {@link DedupingHook} calls this once per evaluation. This implementation answers true when the flag + * is reporting a different result than it last did, and when the window has elapsed on the result + * it is repeating. See {@link EvaluationExposureKey} for what makes two evaluations the same + * result. + * + * @param key the key identifying the evaluation result + * @param nowMillis a reading of a clock that counts from an arbitrary point, in milliseconds. + * {@link DedupingHook} passes {@code SystemClock.elapsedRealtime()}, so that + * correcting the device clock cannot stretch a window. Only differences between + * readings are meaningful: this is not a time of day, and comparing it with + * {@code System.currentTimeMillis()} is a mistake. + * @return true if the hook should observe this evaluation, false if it should be suppressed + */ + public synchronized boolean shouldRecord(EvaluationExposureKey key, long nowMillis) { + if (windowMillis <= 0) { + return true; + } + + TrackedFlag flag = new TrackedFlag(key); + LastReported reported = lastReported.get(flag); + if (reported == null) { + lastReported.put(flag, new LastReported(key, nowMillis)); + return true; + } + if (reported.atMillis > nowMillis - windowMillis && reported.isSameResultAs(key)) { + return false; + } + + reported.update(key, nowMillis); + return true; + } + + /** + * Clears all recorded exposures, so the next evaluation of each is reported again. + * {@link DedupingHook} calls this when the evaluation context changes. + */ + public synchronized void reset() { + lastReported.clear(); + } + + /** + * The flag a record belongs to. The environment is part of it because a hook set on the + * configuration is one instance shared by the clients for every environment in + * {@code secondaryMobileKeys}: were the environments to share a record, each would look like the + * other having changed its result, and neither would ever be suppressed. + */ + private static final class TrackedFlag { + private final String environmentName; + private final String flagKey; + private final int hashCode; + + TrackedFlag(EvaluationExposureKey key) { + this.environmentName = key.getEnvironmentName(); + this.flagKey = key.getFlagKey(); + this.hashCode = 31 * Objects.hashCode(environmentName) + Objects.hashCode(flagKey); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof TrackedFlag)) { + return false; + } + + TrackedFlag o = (TrackedFlag) other; + return hashCode == o.hashCode + && Objects.equals(flagKey, o.flagKey) + && Objects.equals(environmentName, o.environmentName); + } + + @Override + public int hashCode() { + return hashCode; + } + } + + /** + * The result a flag last reported, and when. Mutable so that a flag costs one record for as long + * as it is tracked, however often its result changes. + */ + private static final class LastReported { + private EvaluationExposureKey key; + private long atMillis; + + LastReported(EvaluationExposureKey key, long atMillis) { + update(key, atMillis); + } + + void update(EvaluationExposureKey key, long atMillis) { + this.key = key; + this.atMillis = atMillis; + } + + /** + * Holding the key rather than a copy of the components that describe its result is what keeps + * this from having to be revisited whenever {@link EvaluationExposureKey} gains one. The + * environment and flag key it also compares are equal by the time this is asked, since a + * record is only ever found under the {@link TrackedFlag} they make up. + */ + boolean isSameResultAs(EvaluationExposureKey key) { + return this.key.equals(key); + } + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java new file mode 100644 index 00000000..cb85b411 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKey.java @@ -0,0 +1,166 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.LDValue; + +import java.util.Objects; + +/** + * Identifies the evaluation result a hook is about to be told about, so that an + * {@link EvaluationExposureDeduper} can recognize a repeat of it. + *

+ * Two evaluations are the same exposure when every component here matches. The value is included + * directly rather than inferred from the variation and version: those are the identity LaunchDarkly + * uses to bucket summary events, but neither by itself guarantees that the payload is unchanged. + * The environment is a component because a hook configured on {@code LDConfig} is one instance + * shared by the clients for every environment in {@code secondaryMobileKeys}, and so is its + * deduper. + *

+ * The components describe the result the evaluation returns, which is how the SDK identifies an + * evaluation on analytics events too. An evaluation the SDK has no flag data for returns the default + * value, and so is described by that value with the placeholder variation and version the SDK + * reports for a flag it did not find, the same identity it summarizes such an evaluation under. + * Evaluations made before the client has flags are of that kind, as are evaluations of a flag that + * does not exist, so the data arriving changes the value, variation, and version, and the hook is + * told about the flag again rather than waiting out a window. A flag whose data carries no value, + * which is what a flag that is off without an off variation has, likewise returns the default value + * and is described by it, under the flag's own variation and version. The environment name is never + * unknown this way: it names a mobile key in the configuration, so it is fixed before the client it + * belongs to exists. + *

+ * Instances are immutable, and their hash code is computed once, the first time one is asked for. + */ +public final class EvaluationExposureKey { + private final String environmentName; + private final String flagKey; + private final LDValue value; + private final int variation; + private final int flagVersion; + private final String fullyQualifiedContextKey; + + // Computed on demand, because the SDK's own deduper recognizes a repeat by the flag a key belongs + // to and the result it describes, and so never hashes a whole key: only a deduper of your own + // that holds keys in a map or a set does. Races are benign, as every thread computes the same + // value from fields that cannot change. + private int hashCode; + + /** + * Creates a key with a JSON null value. Prefer the overload accepting {@code value}, since the + * variation and version do not by themselves distinguish one result from another. + * + * @param environmentName the name of the environment the evaluation was made against + * @param flagKey the flag key + * @param variation the variation index of the result + * @param flagVersion the flag version reported on events + * @param fullyQualifiedContextKey the fully qualified key of the evaluation context + */ + public EvaluationExposureKey(String environmentName, String flagKey, int variation, + int flagVersion, String fullyQualifiedContextKey) { + this(environmentName, flagKey, LDValue.ofNull(), variation, flagVersion, + fullyQualifiedContextKey); + } + + /** + * @param environmentName the name of the environment the evaluation was made against + * @param flagKey the flag key + * @param value the value the evaluation returns, which is the default value if the flag was not + * found + * @param variation the variation index of the result + * @param flagVersion the flag version reported on events + * @param fullyQualifiedContextKey the fully qualified key of the evaluation context + */ + public EvaluationExposureKey(String environmentName, String flagKey, LDValue value, int variation, + int flagVersion, String fullyQualifiedContextKey) { + this.environmentName = environmentName; + this.flagKey = flagKey; + this.value = value; + this.variation = variation; + this.flagVersion = flagVersion; + this.fullyQualifiedContextKey = fullyQualifiedContextKey; + } + + /** + * @return the name of the environment the evaluation was made against + */ + public String getEnvironmentName() { + return environmentName; + } + + /** + * @return the flag key + */ + public String getFlagKey() { + return flagKey; + } + + /** + * @return the value the evaluation returns, which is the default value if the flag was not found + */ + public LDValue getValue() { + return value; + } + + /** + * @return the variation index of the result + */ + public int getVariation() { + return variation; + } + + /** + * @return the flag version reported on events + */ + public int getFlagVersion() { + return flagVersion; + } + + /** + * @return the fully qualified key of the evaluation context + */ + public String getFullyQualifiedContextKey() { + return fullyQualifiedContextKey; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof EvaluationExposureKey)) { + return false; + } + + EvaluationExposureKey o = (EvaluationExposureKey) other; + // The primitives reject most unequal keys without touching the strings. + return variation == o.variation + && flagVersion == o.flagVersion + && Objects.equals(flagKey, o.flagKey) + && Objects.equals(environmentName, o.environmentName) + && Objects.equals(value, o.value) + && Objects.equals(fullyQualifiedContextKey, o.fullyQualifiedContextKey); + } + + @Override + public int hashCode() { + int hash = hashCode; + if (hash == 0) { + hash = Objects.hashCode(environmentName); + hash = 31 * hash + Objects.hashCode(flagKey); + hash = 31 * hash + Objects.hashCode(value); + hash = 31 * hash + variation; + hash = 31 * hash + flagVersion; + hash = 31 * hash + Objects.hashCode(fullyQualifiedContextKey); + hashCode = hash; + } + return hash; + } + + @Override + public String toString() { + return "EvaluationExposureKey(environmentName=" + environmentName + + ", flagKey=" + flagKey + + ", value=" + value + + ", variation=" + variation + + ", flagVersion=" + flagVersion + + ", fullyQualifiedContextKey=" + fullyQualifiedContextKey + ")"; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java new file mode 100644 index 00000000..2969a133 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EvaluationExposureKeySupplier.java @@ -0,0 +1,28 @@ +package com.launchdarkly.sdk.android.integrations; + +import androidx.annotation.Nullable; + +import com.launchdarkly.sdk.android.DataModel; + +/** + * Builds the key identifying the result an evaluation is about to return. + *

+ * The SDK gives one of these to each {@link EvaluationSeriesContext} it builds, so that a hook which + * needs the identity of an evaluation can ask for it without the SDK building one for hooks that do + * not. {@link DedupingHook} is the hook that needs it. + *

+ * The whole evaluation is a parameter, rather than the parts of it a key is built from today, so that + * a component added to {@link EvaluationExposureKey} later does not change this signature. The flag is + * the second parameter because the evaluation has already read it: reading it again here would let an + * update landing in between describe a result the evaluation does not return. + */ +@FunctionalInterface +public interface EvaluationExposureKeySupplier { + /** + * @param seriesContext the evaluation whose result is to be identified + * @param flag the evaluation's own read of the flag, which its result is derived from, or + * null if the flag was not found + * @return the key identifying the result the evaluation will return + */ + EvaluationExposureKey exposureKey(EvaluationSeriesContext seriesContext, @Nullable DataModel.Flag flag); +} 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 index d2043c70..263b89a1 100644 --- 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 @@ -1,7 +1,10 @@ package com.launchdarkly.sdk.android.integrations; +import androidx.annotation.Nullable; + import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.DataModel; import java.util.Map; import java.util.Objects; @@ -33,6 +36,12 @@ public class EvaluationSeriesContext { */ public final LDValue defaultValue; + private final EvaluationExposureKeySupplier exposureKeySupplier; + + // The evaluation's own read of the flag, held rather than the key describing it so that an + // evaluation reaching only hooks that never ask what its result is builds nothing to describe it. + private final DataModel.Flag flag; + /** * @param method the variation method that was used to invoke the evaluation. * @param key the key of the feature flag being evaluated. @@ -40,10 +49,49 @@ public class EvaluationSeriesContext { * @param defaultValue the user-provided default value for the evaluation. */ public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue) { + this(method, key, context, defaultValue, null, null); + } + + /** + * Used by the SDK, which has read the flag the evaluation will return a result from. Application + * code has no use for this constructor: a context built with the four-argument one has no exposure + * key, and {@link #getEvaluationExposureKey()} explains what that means for a hook that wanted one. + * + * @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. + * @param exposureKeySupplier builds the key identifying the result of this evaluation, or null if + * the result is not known + * @param flag the evaluation's own read of the flag, or null if it was not found + */ + public EvaluationSeriesContext(String method, String key, LDContext context, LDValue defaultValue, + @Nullable EvaluationExposureKeySupplier exposureKeySupplier, + @Nullable DataModel.Flag flag) { this.flagKey = key; this.context = context; this.defaultValue = defaultValue; this.method = method; + this.exposureKeySupplier = exposureKeySupplier; + this.flag = flag; + } + + /** + * Returns the key identifying the result this evaluation will return, for a hook that decides what + * to do with an evaluation by whether it has seen the same result before. {@link DedupingHook} is + * such a hook. + *

+ * The key describes the evaluation's own read of the flag, the one its result is derived from, so + * it is available to {@link Hook#beforeEvaluation(EvaluationSeriesContext, Map)} as well as to the + * after stage, and every hook that asks is told about the same result. It is built on the ask, so + * an evaluation whose hooks never ask does not pay for one. + * + * @return the key identifying this evaluation's result, or null if this context was not built by + * the SDK and so has no result to describe + */ + @Nullable + public EvaluationExposureKey getEvaluationExposureKey() { + return exposureKeySupplier == null ? null : exposureKeySupplier.exposureKey(this, flag); } @Override 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 index a9b88747..841ab606 100644 --- 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 @@ -12,6 +12,9 @@ * 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) + *

+ * To deduplicate the repeated evaluations observed by one hook, wrap it in a + * {@link DedupingHook} and register the wrapper. */ public abstract class Hook { 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 index d951f770..f775ff29 100644 --- 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 @@ -23,6 +23,17 @@ * .build(); * *

+ * A hook observes every evaluation unless it is wrapped in a {@link DedupingHook}, which limits how + * often repeated evaluations resolving to the same result reach it. + * + *


+ *     Components.hooks()
+ *         .addHook(new MetricsHook())
+ *         .addHook(new DedupingHook(new ObservabilityHook()))
+ *         .addHook(new DedupingHook(new TelemetryHook(), 60_000))
+ *         .addHook(new DedupingHook(new ExperimentHook(), myCustomDeduper))
+ * 
+ *

* Note that this class is abstract; the actual implementation is created by calling {@link Components#hooks()}. */ public abstract class HooksConfigurationBuilder { @@ -45,8 +56,21 @@ public HooksConfigurationBuilder setHooks(List hooks) { return this; } + /** + * Adds a hook 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 hook to be added to the configuration + * @return the builder + */ + public HooksConfigurationBuilder addHook(Hook hook) { + List hooks = new ArrayList<>(this.hooks); + hooks.add(hook); + return setHooks(hooks); + } + /** * @return the hooks configuration */ abstract public HookConfiguration build(); -} \ No newline at end of file +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java new file mode 100644 index 00000000..085b786b --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EvaluationExposureDeduperTest.java @@ -0,0 +1,240 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureDeduper; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; +import com.launchdarkly.sdk.android.subsystems.EventProcessor; + +import org.junit.Test; + + +public class EvaluationExposureDeduperTest { + /** + * An exposure key that differs from every other one this test builds only by its flag key, so + * that a test can talk about "the exposure of a" without spelling out the whole key. + */ + private static EvaluationExposureKey key(String flagKey) { + return new EvaluationExposureKey( + "default", flagKey, LDValue.of("value"), 1, 2, "user-key"); + } + + /** + * The same flag as {@link #key(String)}, resolved to a different variation. + */ + private static EvaluationExposureKey otherResult(String flagKey) { + return new EvaluationExposureKey( + "default", flagKey, LDValue.of("other-value"), 3, 2, "user-key"); + } + + /** + * The key the SDK builds for an evaluation it has no flag data for: one made before the client + * has flags, or one of a flag that does not exist. Such an evaluation returns the default value, + * so that is the value describing it, under the placeholders the SDK reports on events. + */ + private static EvaluationExposureKey unknownFlag(String flagKey, LDValue defaultValue) { + return new EvaluationExposureKey("default", flagKey, defaultValue, + EvaluationDetail.NO_VARIATION, EventProcessor.NO_VERSION, "user-key"); + } + + @Test + public void recordsEverythingForNonPositiveWindow() { + for (int window : new int[] { 0, -1 }) { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(window); + assertTrue(deduper.shouldRecord(key("a"), 0)); + assertTrue(deduper.shouldRecord(key("a"), 0)); + } + } + + @Test + public void exposureKeyDistinguishesEveryComponent() { + EvaluationExposureKey key = new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 2, "user-key"); + EvaluationExposureKey same = new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 2, "user-key"); + assertEquals(key, same); + assertEquals(key.hashCode(), same.hashCode()); + + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("other-value"), 1, 2, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "other-flag", LDValue.of("value"), 1, 2, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 3, 2, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 4, "user-key")); + assertNotEquals(key, new EvaluationExposureKey( + "default", "flag", LDValue.of("value"), 1, 2, "other-user-key")); + // A hook shared across environments observes the same result once per environment. + assertNotEquals(key, new EvaluationExposureKey( + "other-env", "flag", LDValue.of("value"), 1, 2, "user-key")); + } + + @Test + public void exposureKeysWithNoFlagDataAreEqualWhenTheDefaultIs() { + EvaluationExposureKey key = unknownFlag("flag", LDValue.of(false)); + EvaluationExposureKey same = unknownFlag("flag", LDValue.of(false)); + assertEquals(key, same); + assertEquals(key.hashCode(), same.hashCode()); + + assertNotEquals(key, unknownFlag("flag", LDValue.of(true))); + } + + @Test + public void suppressesRepeatsWithinWindow() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertFalse(deduper.shouldRecord(key("a"), 1000)); + assertFalse(deduper.shouldRecord(key("a"), 1099)); + } + + @Test + public void recordsAgainOnceWindowElapses() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertTrue(deduper.shouldRecord(key("a"), 1100)); + // Recording restarts the window rather than extending the original one. + assertFalse(deduper.shouldRecord(key("a"), 1150)); + assertTrue(deduper.shouldRecord(key("a"), 1200)); + } + + @Test + public void tracksFlagsIndependently() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertTrue(deduper.shouldRecord(key("b"), 1000)); + assertFalse(deduper.shouldRecord(key("a"), 1000)); + assertFalse(deduper.shouldRecord(key("b"), 1000)); + } + + @Test + public void reportsAFlagAgainAsSoonAsItsResultChanges() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertTrue(deduper.shouldRecord(otherResult("a"), 1010)); + assertFalse(deduper.shouldRecord(otherResult("a"), 1020)); + // Only the result the flag reported last is tracked, so flipping back is a change too and the + // hook is told about it rather than being left to think the flag never returned to it. + assertTrue(deduper.shouldRecord(key("a"), 1030)); + assertFalse(deduper.shouldRecord(key("a"), 1040)); + } + + @Test + public void reportsAgainWhenOnlyTheFlagValueChanges() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + EvaluationExposureKey first = new EvaluationExposureKey( + "default", "flag", LDValue.of("first"), 1, 2, "user-key"); + EvaluationExposureKey second = new EvaluationExposureKey( + "default", "flag", LDValue.of("second"), 1, 2, "user-key"); + + assertTrue(deduper.shouldRecord(first, 1000)); + assertTrue(deduper.shouldRecord(second, 1010)); + assertFalse(deduper.shouldRecord(second, 1020)); + } + + @Test + public void treatsEvaluationsWithNoFlagDataResolvingToTheSameDefaultAsOneExposure() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + + // Evaluations the SDK has no flag data for return the default value, and repeats of that result + // are a repeat like any other. + assertTrue(deduper.shouldRecord(unknownFlag("a", LDValue.of(false)), 1000)); + assertFalse(deduper.shouldRecord(unknownFlag("a", LDValue.of(false)), 1010)); + + // A different default is a different result, because it is a different value returned to the + // application. + assertTrue(deduper.shouldRecord(unknownFlag("a", LDValue.of(true)), 1020)); + assertFalse(deduper.shouldRecord(unknownFlag("a", LDValue.of(true)), 1030)); + } + + @Test + public void reportsAgainWhenTheFlagBecomesKnown() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + assertTrue(deduper.shouldRecord(unknownFlag("a", LDValue.of("value")), 1000)); + + // The data arriving is a change of result even when the flag resolves to the value the default + // had already produced, because the variation and version are no longer the placeholders the + // SDK uses for a flag it did not find. So is the flag going away again. + assertTrue(deduper.shouldRecord(key("a"), 1010)); + assertFalse(deduper.shouldRecord(key("a"), 1020)); + assertTrue(deduper.shouldRecord(unknownFlag("a", LDValue.of("value")), 1030)); + } + + @Test + public void tracksTheSameFlagSeparatelyPerEnvironment() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + EvaluationExposureKey primary = new EvaluationExposureKey("default", "flag", 1, 2, "user-key"); + EvaluationExposureKey secondary = new EvaluationExposureKey("other", "flag", 3, 4, "user-key"); + + // A hook set on the configuration is shared by the clients for every environment, so its + // deduper sees both. Neither environment may look to the other like its result changing. + assertTrue(deduper.shouldRecord(primary, 1000)); + assertTrue(deduper.shouldRecord(secondary, 1000)); + assertFalse(deduper.shouldRecord(primary, 1010)); + assertFalse(deduper.shouldRecord(secondary, 1010)); + } + + @Test + public void recordsAgainAfterReset() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(100); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + deduper.reset(); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + } + + @Test + public void usesTheDefaultWindowWhenBuiltWithoutOne() { + // Ten minutes. + assertEquals(600_000, EvaluationExposureDeduper.DEFAULT_WINDOW_MILLIS); + + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(); + assertTrue(deduper.shouldRecord(key("a"), 1000)); + assertFalse(deduper.shouldRecord(key("a"), 600_999)); + assertTrue(deduper.shouldRecord(key("a"), 601_000)); + } + + @Test + public void tracksEveryFlagTheApplicationEvaluates() { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(600_000); + for (int i = 0; i < 2_000; i++) { + assertTrue(deduper.shouldRecord(key("key-" + i), 1000)); + } + + // Records accumulate; the first flag is still suppressed after two thousand others have been recorded. + assertFalse(deduper.shouldRecord(key("key-0"), 1000)); + assertFalse(deduper.shouldRecord(key("key-1999"), 1000)); + } + + @Test + public void recordsOnceWhenSameKeyIsCheckedConcurrently() throws Exception { + EvaluationExposureDeduper deduper = new EvaluationExposureDeduper(60_000); + EvaluationExposureKey key = key("a"); + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + boolean[] recorded = new boolean[threadCount]; + + for (int i = 0; i < threadCount; i++) { + final int index = i; + threads[i] = new Thread(() -> recorded[index] = deduper.shouldRecord(key, 1000)); + } + for (Thread thread : threads) { + thread.start(); + } + for (Thread thread : threads) { + thread.join(); + } + + int recordedCount = 0; + for (boolean value : recorded) { + if (value) { + recordedCount++; + } + } + assertEquals(1, recordedCount); + } +} 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 index af1fdaf1..efcca099 100644 --- 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 @@ -10,6 +10,8 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.integrations.EvaluationExposureKey; import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.HookMetadata; @@ -28,6 +30,10 @@ import java.util.Map; public class HookRunnerTest extends EasyMockSupport { + // Every evaluation in these tests is the same exposure, so the runner's supplier returns this. + private static final EvaluationExposureKey EXPOSURE_KEY = + new EvaluationExposureKey("default", "test-flag", 1, 2, "user-123"); + private HookRunner hookRunner; private Hook testHook; @@ -68,6 +74,104 @@ public void executesHooksAndReturnsEvaluationResult() { logging.assertNothingLogged(); } + /** + * Records the evaluation stages it observes, so a test can tell a suppressed evaluation (no + * stages) from a reported one. + */ + private static class RecordingHook extends Hook { + final List stages = new ArrayList<>(); + + RecordingHook(String name) { + super(name); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + stages.add("before"); + return seriesData; + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + stages.add("after"); + return seriesData; + } + } + + private void evaluate(HookRunner runner) { + runner.withEvaluation("testMethod", "test-flag", LDContext.create("user-123"), LDValue.of(false), + () -> EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off())); + } + + /** + * Reads what an evaluation's result identifies, the way a deduping hook does, so a test can tell + * when the runner resolved that and how often. + */ + private static class KeyReadingHook extends Hook { + final List keys = new ArrayList<>(); + + KeyReadingHook(String name) { + super(name); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + keys.add(seriesContext.getEvaluationExposureKey()); + return seriesData; + } + } + + @Test + public void tellsAHookWhatTheEvaluationsResultIdentifies() { + KeyReadingHook hook = new KeyReadingHook("key-reading"); + HookRunner runner = new HookRunner(logging.logger, List.of(hook), + (seriesContext, flag) -> EXPOSURE_KEY); + + evaluate(runner); + + assertEquals(List.of(EXPOSURE_KEY), hook.keys); + } + + @Test + public void doesNotBuildTheExposureKeyUnlessAHookAsksForIt() { + RecordingHook hook = new RecordingHook("reporting-everything"); + List keyRequests = new ArrayList<>(); + HookRunner runner = new HookRunner(logging.logger, List.of(hook), + (seriesContext, flag) -> { + keyRequests.add(seriesContext.flagKey); + return EXPOSURE_KEY; + }); + + evaluate(runner); + + assertEquals(List.of(), keyRequests); + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void describesTheEvaluationsOwnReadOfTheFlagToEveryHookThatAsks() { + KeyReadingHook first = new KeyReadingHook("first"); + KeyReadingHook second = new KeyReadingHook("second"); + List described = new ArrayList<>(); + HookRunner runner = new HookRunner(logging.logger, List.of(first, second), + (seriesContext, flag) -> { + described.add(flag); + return EXPOSURE_KEY; + }); + Flag flag = new FlagBuilder("test-flag").version(2).build(); + + runner.withEvaluation("testMethod", "test-flag", LDContext.create("user-123"), LDValue.of(false), flag, + () -> EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off())); + + // Both hooks describe the read handed to the evaluation, rather than a later look at the store. + assertEquals(2, described.size()); + assertSame(flag, described.get(0)); + assertSame(flag, described.get(1)); + assertEquals(List.of(EXPOSURE_KEY), first.keys); + assertEquals(List.of(EXPOSURE_KEY), second.keys); + } + @Test public void handlesErrorInEvaluationHooks() { String method = "testMethod"; diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java new file mode 100644 index 00000000..dfebf5e1 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/DedupingHookTest.java @@ -0,0 +1,344 @@ +package com.launchdarkly.sdk.android.integrations; + +import static org.junit.Assert.assertEquals; + +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.HookRunner; +import com.launchdarkly.sdk.android.LogCaptureRule; + +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Drives hooks through {@link HookRunner} rather than calling the decorator directly, because how the + * runner carries series data from one stage to the next is what a decorator has to work with. + *

+ * Lives in the decorator's own package so that it can hand it a clock, since what the SDK reads is + * {@code SystemClock}, which a unit test cannot call. + */ +public class DedupingHookTest { + private static final EvaluationExposureKey EXPOSURE_KEY = + new EvaluationExposureKey("default", "test-flag", 1, 2, "user-123"); + private static final EvaluationExposureKey OTHER_RESULT = + new EvaluationExposureKey("default", "test-flag", 2, 2, "user-123"); + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + /** Lets a test decide how much time has passed, and how much has not. */ + private static final class FakeClock implements DedupingHook.Clock { + long millis = 1_000; + + @Override + public long elapsedMillis() { + return millis; + } + } + + private final FakeClock clock = new FakeClock(); + + /** + * Records the stages it observes, so a test can tell a suppressed evaluation (no stages) from a + * reported one. + */ + private static class RecordingHook extends Hook { + final List stages = new ArrayList<>(); + + RecordingHook(String name) { + super(name); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + stages.add("before"); + return seriesData; + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + stages.add("after"); + return seriesData; + } + + @Override + public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { + stages.add("beforeIdentify"); + return seriesData; + } + + @Override + public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, + IdentifySeriesResult result) { + stages.add("afterIdentify"); + return seriesData; + } + + @Override + public void afterTrack(TrackSeriesContext seriesContext) { + stages.add("afterTrack"); + } + } + + /** A wrapper with its own behavior, to check that hook wrappers compose. */ + private static class CountingDecorator extends Hook { + private final Hook delegate; + int evaluationsForwarded = 0; + int resultsForwarded = 0; + + CountingDecorator(Hook delegate) { + super(delegate.getMetadata().getName()); + this.delegate = delegate; + } + + @Override + public HookMetadata getMetadata() { + return delegate.getMetadata(); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + evaluationsForwarded++; + return delegate.beforeEvaluation(seriesContext, seriesData); + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, + EvaluationDetail evaluationDetail) { + resultsForwarded++; + return delegate.afterEvaluation(seriesContext, seriesData, evaluationDetail); + } + } + + private DedupingHook deduping(Hook delegate, int windowMillis) { + return new DedupingHook(delegate, new EvaluationExposureDeduper(windowMillis), clock); + } + + private DedupingHook deduping(Hook delegate, EvaluationExposureDeduper deduper) { + return new DedupingHook(delegate, deduper, clock); + } + + private HookRunner runner(EvaluationExposureKey key, Hook... hooks) { + return new HookRunner(logging.logger, List.of(hooks), (seriesContext, flag) -> key); + } + + private void evaluate(HookRunner runner) { + runner.withEvaluation("testMethod", "test-flag", LDContext.create("user-123"), LDValue.of(false), + () -> EvaluationDetail.fromValue(LDValue.of(true), 1, EvaluationReason.off())); + } + + private void identify(HookRunner runner) { + runner.identify(LDContext.create("user-123"), null).invoke(new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.COMPLETED)); + } + + @Test + public void skipsBothStagesOfARepeatedEvaluation() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, 60_000)); + + evaluate(runner); + evaluate(runner); + + // A suppressed evaluation must not leave a beforeEvaluation unmatched by its afterEvaluation. + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void reportsTheSameResultAgainOnceTheWindowElapses() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, 60_000)); + + evaluate(runner); + clock.millis += 59_999; + evaluate(runner); + clock.millis += 1; + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + + @Test + public void usesTheDefaultWindowWhenWrappedWithoutOne() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, new EvaluationExposureDeduper())); + + evaluate(runner); + clock.millis += EvaluationExposureDeduper.DEFAULT_WINDOW_MILLIS - 1; + evaluate(runner); + + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void anUnwrappedHookObservesEveryEvaluation() { + RecordingHook hook = new RecordingHook("unwrapped"); + HookRunner runner = runner(EXPOSURE_KEY, hook); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + + @Test + public void reportsAnEvaluationWhoseResultChanged() { + RecordingHook hook = new RecordingHook("deduping"); + List keys = new ArrayList<>(List.of(EXPOSURE_KEY, EXPOSURE_KEY, OTHER_RESULT)); + HookRunner runner = new HookRunner(logging.logger, List.of(deduping(hook, 60_000)), + (seriesContext, flag) -> keys.remove(0)); + + evaluate(runner); + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + + @Test + public void wrappedHooksAreDeduplicatedIndependentlyOfEachOther() { + RecordingHook wrapped = new RecordingHook("deduping"); + RecordingHook reportingEverything = new RecordingHook("reporting-everything"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(wrapped, 60_000), reportingEverything); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after"), wrapped.stages); + assertEquals(List.of("before", "after", "before", "after"), reportingEverything.stages); + } + + @Test + public void hooksWrappedSeparatelyDoNotSuppressEachOther() { + RecordingHook first = new RecordingHook("first"); + RecordingHook second = new RecordingHook("second"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(first, 60_000), deduping(second, 60_000)); + + evaluate(runner); + evaluate(runner); + + // Sharing one deduper would have let the first hook's report suppress the second hook's. + assertEquals(List.of("before", "after"), first.stages); + assertEquals(List.of("before", "after"), second.stages); + } + + @Test + public void hooksSharingOneDeduperShareItsWindow() { + EvaluationExposureDeduper shared = new EvaluationExposureDeduper(60_000); + RecordingHook first = new RecordingHook("first"); + RecordingHook second = new RecordingHook("second"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(first, shared), deduping(second, shared)); + + evaluate(runner); + + // The first hook's report starts the window, which suppresses the second hook's. + assertEquals(List.of("before", "after"), first.stages); + assertEquals(List.of(), second.stages); + } + + @Test + public void identifyReportsTheSameEvaluationAgain() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, 60_000)); + + evaluate(runner); + identify(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "beforeIdentify", "afterIdentify", "before", "after"), + hook.stages); + } + + @Test + public void forwardsTheStagesItDoesNotDeduplicate() { + RecordingHook hook = new RecordingHook("deduping"); + HookRunner runner = runner(EXPOSURE_KEY, deduping(hook, 60_000)); + + identify(runner); + runner.afterTrack("event-key", LDContext.create("user-123"), LDValue.ofNull(), null); + + assertEquals(List.of("beforeIdentify", "afterIdentify", "afterTrack"), hook.stages); + } + + @Test + public void forwardsAnEvaluationWhoseResultTheSdkDidNotDescribe() { + RecordingHook hook = new RecordingHook("deduping"); + // A series context built by something other than the SDK has no result to recognize repeats + // by, so nothing is suppressed. + HookRunner runner = new HookRunner(logging.logger, List.of(deduping(hook, 60_000)), + (seriesContext, flag) -> null); + + evaluate(runner); + evaluate(runner); + + assertEquals(List.of("before", "after", "before", "after"), hook.stages); + } + + @Test + public void deduperStacksInsideAnotherDecorator() { + RecordingHook hook = new RecordingHook("wrapped-twice"); + CountingDecorator counting = new CountingDecorator(deduping(hook, 60_000)); + HookRunner runner = runner(EXPOSURE_KEY, counting); + + evaluate(runner); + evaluate(runner); + + // The outer decorator sees both evaluations, and the deduper inside it passes on one. This is + // the arrangement the documentation advises against, and it works as long as the decorator + // outside the deduper does not store series data, which a suppressed evaluation replaces. + assertEquals(2, counting.evaluationsForwarded); + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void deduperStacksAroundAnotherDecorator() { + RecordingHook hook = new RecordingHook("wrapped-twice"); + CountingDecorator counting = new CountingDecorator(hook); + HookRunner runner = runner(EXPOSURE_KEY, deduping(counting, 60_000)); + + evaluate(runner); + evaluate(runner); + + // The deduper is outermost this time, so the decorator inside it sees only what it forwards. + assertEquals(1, counting.evaluationsForwarded); + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void aDeduperDoesNotSwallowTheStagesOfADeduperInsideIt() { + RecordingHook hook = new RecordingHook("deduped-twice"); + CountingDecorator counting = new CountingDecorator(deduping(hook, 60_000)); + // The outer deduper reports everything, so what the inner one suppresses has to travel back + // out through the decorator between them, which each stage of still belongs to. + HookRunner runner = runner(EXPOSURE_KEY, deduping(counting, 0)); + + evaluate(runner); + evaluate(runner); + + assertEquals(2, counting.evaluationsForwarded); + assertEquals(2, counting.resultsForwarded); + assertEquals(List.of("before", "after"), hook.stages); + } + + @Test + public void namesTheWrappedHookWhenItReportsAnError() { + Hook throwing = new Hook("throwing-hook") { + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + throw new RuntimeException("Hook error"); + } + }; + HookRunner runner = runner(EXPOSURE_KEY, deduping(throwing, 60_000)); + + evaluate(runner); + + logging.assertErrorLogged("During evaluation of flag \"test-flag\". Stage \"beforeEvaluation\" " + + "of hook \"throwing-hook\" reported error: java.lang.RuntimeException: Hook error"); + } +}