From 2ebaa96a037d4908fcca40cfe4e7ea2d36c8ad1a Mon Sep 17 00:00:00 2001 From: deleteLater Date: Tue, 3 Nov 2026 23:09:30 +0800 Subject: [PATCH] feat: respect flag insights collection setting --- .../java/co/featbit/server/DataModel.java | 6 + .../java/co/featbit/server/FBClientImp.java | 12 +- .../java/co/featbit/server/Implicits.java | 9 +- .../featbit/server/InsightsEnabledTest.java | 124 ++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 src/test/java/co/featbit/server/InsightsEnabledTest.java diff --git a/src/main/java/co/featbit/server/DataModel.java b/src/main/java/co/featbit/server/DataModel.java index e3b7c81..d73a715 100644 --- a/src/main/java/co/featbit/server/DataModel.java +++ b/src/main/java/co/featbit/server/DataModel.java @@ -263,6 +263,8 @@ static class FeatureFlag implements DataStorageTypes.Item, JsonHelper.AfterJsonP private Long timestamp; private final boolean isArchived; private final boolean exptIncludeAllTargets; + // Missing values from older servers keep evaluation event collection enabled. + private Boolean insightsEnabled; private final boolean isEnabled; private final String name; private final String key; @@ -318,6 +320,10 @@ public boolean exptIncludeAllTargets() { return exptIncludeAllTargets; } + public boolean isInsightsEnabled() { + return !Boolean.FALSE.equals(insightsEnabled); + } + public boolean isEnabled() { return isEnabled; } diff --git a/src/main/java/co/featbit/server/FBClientImp.java b/src/main/java/co/featbit/server/FBClientImp.java index 8ae59cd..a7d316c 100644 --- a/src/main/java/co/featbit/server/FBClientImp.java +++ b/src/main/java/co/featbit/server/FBClientImp.java @@ -12,8 +12,10 @@ import java.io.IOException; import java.time.Duration; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.*; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -252,7 +254,9 @@ private Evaluator.EvalResult evaluateInternal(String featureFlagKey, FBUser user Loggers.EVALUATION.warn("FB JAVA SDK: evaluation result {} didn't matched expected type {}", res.getValue(), requiredType); return Evaluator.EvalResult.error(dv, REASON_WRONG_TYPE, res.getKeyName(), res.getName()); } - eventHandler.accept(event); + if (flag.isInsightsEnabled()) { + eventHandler.accept(event); + } return res; } catch (Exception ex) { logger.error("FB JAVA SDK: unexpected error in evaluation", ex); @@ -316,6 +320,7 @@ public boolean initializeFromExternalJson(String json) { @Override public AllFlagStates getAllLatestFlagsVariations(FBUser user) { ImmutableMap.Builder builder = ImmutableMap.builder(); + Set insightsEnabledFlags = new HashSet<>(); boolean success = true; String errorString = ""; try { @@ -334,6 +339,9 @@ public AllFlagStates getAllLatestFlagsVariations(FBUser user) { DataModel.FeatureFlag flag = (DataModel.FeatureFlag) item; Evaluator.EvalResult res = evaluator.evaluate(flag, user, event); builder.put(res, event); + if (flag.isInsightsEnabled()) { + insightsEnabledFlags.add(flag.getKey()); + } } } } catch (Exception ex) { @@ -341,7 +349,7 @@ public AllFlagStates getAllLatestFlagsVariations(FBUser user) { success = false; errorString = REASON_ERROR; } - return new Implicits.ComplexAllFlagStates(success, errorString, builder.build(), eventHandler); + return new Implicits.ComplexAllFlagStates(success, errorString, builder.build(), eventHandler, insightsEnabledFlags); } @Override diff --git a/src/main/java/co/featbit/server/Implicits.java b/src/main/java/co/featbit/server/Implicits.java index b143568..d929f94 100644 --- a/src/main/java/co/featbit/server/Implicits.java +++ b/src/main/java/co/featbit/server/Implicits.java @@ -3,11 +3,13 @@ import co.featbit.commons.model.AllFlagStates; import co.featbit.commons.model.EvalDetail; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import org.apache.commons.lang3.BooleanUtils; import java.util.AbstractMap; import java.util.Collection; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import static co.featbit.server.Evaluator.*; @@ -20,6 +22,7 @@ static final class ComplexAllFlagStates implements AllFlagStates { private String reason; private transient final Consumer eventHandler; + private transient final Set insightsEnabledFlags; private transient final Map> cache; @@ -27,10 +30,12 @@ static final class ComplexAllFlagStates implements AllFlagStates { ComplexAllFlagStates(boolean success, String reason, Map data, - Consumer eventHandler) { + Consumer eventHandler, + Set insightsEnabledFlags) { this.success = success; this.reason = reason; this.eventHandler = eventHandler; + this.insightsEnabledFlags = ImmutableSet.copyOf(insightsEnabledFlags); ImmutableMap.Builder> builder = ImmutableMap.builder(); data.forEach((evalResult, event) -> builder.put(evalResult.getKeyName(), new AbstractMap.SimpleImmutableEntry<>(evalResult, event))); cache = builder.build(); @@ -58,7 +63,7 @@ private Evaluator.EvalResult getInternal(String flagKeyName, Object defaultValue } Evaluator.EvalResult er = entry.getKey(); if (Utils.checkType(er.getFlagType(), requiredType, defaultValue.toString())) { - if (eventHandler != null) { + if (eventHandler != null && insightsEnabledFlags.contains(flagKeyName)) { InsightTypes.FlagEvent event = entry.getValue(); event.updateTimeStamp(); eventHandler.accept(event); diff --git a/src/test/java/co/featbit/server/InsightsEnabledTest.java b/src/test/java/co/featbit/server/InsightsEnabledTest.java new file mode 100644 index 0000000..451a204 --- /dev/null +++ b/src/test/java/co/featbit/server/InsightsEnabledTest.java @@ -0,0 +1,124 @@ +package co.featbit.server; + +import co.featbit.commons.json.JsonHelper; +import co.featbit.commons.model.AllFlagStates; +import co.featbit.commons.model.EvalDetail; +import co.featbit.commons.model.FBUser; +import co.featbit.server.exterior.DataStorageTypes; +import co.featbit.server.exterior.InsightProcessor; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class InsightsEnabledTest extends FBClientBaseTest { + private static final String KEY = "ff-test-bool"; + private final FBUser user = new FBUser.Builder("test-user-1").userName("test-user-1").build(); + + private DataModel.Data data(Boolean enabled) throws Exception { + JsonObject envelope = JsonParser.parseString(readResource("fbclient_test_data.json")).getAsJsonObject(); + for (JsonElement element : envelope.getAsJsonObject("data").getAsJsonArray("featureFlags")) { + if (enabled != null && KEY.equals(element.getAsJsonObject().get("key").getAsString())) { + element.getAsJsonObject().addProperty("insightsEnabled", enabled); + } + } + return JsonHelper.deserialize(envelope.toString(), DataModel.All.class).data(); + } + + private FBClientImp client(InMemoryDataStorage storage, RecordingProcessor processor) { + return new FBClientImp(fakeEnvSecret, new FBConfig.Builder() + .streamingURL("ws://fake-url").eventURL("http://fake-url") + .startWaitTime(Duration.ZERO) + .dataStorageFactory(context -> storage) + .dataSynchronizerFactory(Factory.externalDataSynchronization()) + .insightProcessorFactory(context -> processor).build()); + } + + @Test + void singleAndBulkReadsRespectInsightsWithoutChangingResults() throws Exception { + for (Boolean enabled : new Boolean[]{null, true, false}) { + DataModel.Data data = data(enabled); + InMemoryDataStorage storage = new InMemoryDataStorage(); + storage.init(data.toStorageType(), data.getTimestamp()); + RecordingProcessor processor = new RecordingProcessor(); + try (FBClientImp client = client(storage, processor)) { + EvalDetail detail = client.boolVariationDetail(KEY, user, false); + assertTrue(detail.getVariation()); + assertEquals(Evaluator.REASON_TARGET_MATCH, detail.getReason()); + assertEquals(Boolean.FALSE.equals(enabled) ? 0 : 1, processor.events.size()); + processor.events.clear(); + + AllFlagStates states = client.getAllLatestFlagsVariations(user); + assertTrue(states.isSuccess()); + assertEquals(0, processor.events.size()); + detail = states.getBooleanDetail(KEY, false); + assertTrue(detail.getVariation()); + assertEquals(Evaluator.REASON_TARGET_MATCH, detail.getReason()); + assertEquals(Boolean.FALSE.equals(enabled) ? 0 : 1, processor.events.size()); + } + } + } + + @Test + void bulkReadsApplyInsightsPerFlag() throws Exception { + DataModel.Data data = data(false); + InMemoryDataStorage storage = new InMemoryDataStorage(); + storage.init(data.toStorageType(), data.getTimestamp()); + RecordingProcessor processor = new RecordingProcessor(); + try (FBClientImp client = client(storage, processor)) { + AllFlagStates states = client.getAllLatestFlagsVariations(user); + assertTrue(states.getBoolean(KEY, false)); + assertEquals(0, processor.events.size()); + assertEquals("others", states.getString("ff-test-string", "fallback")); + assertEquals(1, processor.events.size()); + } + } + + @Test + void exportedDataPreservesExplicitFalse() throws Exception { + DataModel.Data data = data(false); + InMemoryDataStorage storage = new InMemoryDataStorage(); + storage.init(data.toStorageType(), data.getTimestamp()); + try (FBClientImp client = client(storage, new RecordingProcessor())) { + DataModel.DataExposer exported = JsonHelper.deserialize(client.getAllDataAsJson(), DataModel.DataExposer.class); + assertFalse(exported.getFeatureFlags().stream().filter(flag -> KEY.equals(flag.getKey())) + .findFirst().get().isInsightsEnabled()); + } + } + + @Test + void updatesToggleCollectionAndBulkResultsRetainTheirSnapshot() throws Exception { + DataModel.Data data = data(false); + InMemoryDataStorage storage = new InMemoryDataStorage(); + storage.init(data.toStorageType(), data.getTimestamp()); + RecordingProcessor processor = new RecordingProcessor(); + try (FBClientImp client = client(storage, processor)) { + AllFlagStates disabledSnapshot = client.getAllLatestFlagsVariations(user); + DataModel.FeatureFlag flag = data(true).getFeatureFlags().stream() + .filter(item -> KEY.equals(item.getKey())).findFirst().get(); + flag.updatedAt.setTime(storage.getVersion() + 1); + flag.afterDeserialization(); + assertTrue(storage.upsert(DataStorageTypes.FEATURES, KEY, flag, flag.getTimestamp())); + assertTrue(client.boolVariation(KEY, user, false)); + assertTrue(client.getAllLatestFlagsVariations(user).getBoolean(KEY, false)); + assertEquals(2, processor.events.size()); + processor.events.clear(); + assertTrue(disabledSnapshot.getBoolean(KEY, false)); + assertEquals(0, processor.events.size()); + } + } + + private static class RecordingProcessor implements InsightProcessor { + final List events = new ArrayList<>(); + + public void send(InsightTypes.Event event) { events.add(event); } + public void flush() { } + public void close() { } + } +}