diff --git a/posthog/src/main/java/com/posthog/java/FeatureFlagPoller.java b/posthog/src/main/java/com/posthog/java/FeatureFlagPoller.java new file mode 100644 index 0000000..84f06ec --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/FeatureFlagPoller.java @@ -0,0 +1,517 @@ +package com.posthog.java; + +import com.posthog.java.flags.*; +import com.posthog.java.flags.hash.Hasher; +import org.json.JSONObject; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.util.concurrent.TimeUnit.*; + +public class FeatureFlagPoller { + private final String projectApiKey; + private final String personalApiKey; + private final CountDownLatch initialLoadLatch; + private final Duration featureFlagPollingInterval; + private final ScheduledExecutorService executor; + private final Getter getter; + private volatile List featureFlags = new ArrayList<>(); + private volatile Map cohorts = new HashMap<>(); + private volatile Map groups = new HashMap<>(); + + private FeatureFlagPoller(Builder builder) { + this.executor = builder.executor; + this.projectApiKey = builder.projectApiKey; + this.personalApiKey = builder.personalApiKey; + this.initialLoadLatch = new CountDownLatch(1); + this.featureFlagPollingInterval = builder.featureFlagPollingInterval; + this.getter = builder.getter; + } + + public static class Builder { + private final String projectApiKey; + private final String personalApiKey; + private final Getter getter; + + private Duration featureFlagPollingInterval = Duration.ofSeconds(300); + private ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + public Builder(String projectApiKey, String personalApiKey, Getter getter) { + this.projectApiKey = projectApiKey; + this.personalApiKey = personalApiKey; + this.getter = getter; + } + + public Builder featureFlagPollingInterval(Duration featureFlagPollingInterval) { + this.featureFlagPollingInterval = featureFlagPollingInterval; + return this; + } + + public Builder executor(ScheduledExecutorService executor) { + this.executor = executor; + return this; + } + + public FeatureFlagPoller build() { + return new FeatureFlagPoller(this); + } + } + + /** + * Polls the PostHog API for feature flags at the specified interval. + * The feature flags are stored in memory and can be accessed using the other methods in this class. + * This method will block until the initial load of feature flags is complete. + */ + public void poll() { + this.executor.scheduleAtFixedRate(() -> { + this.fetchFeatureFlags(); + this.initialLoadLatch.countDown(); + }, 0, this.featureFlagPollingInterval.getSeconds(), SECONDS); + } + + private void fetchFeatureFlags() { + final String url = String.format("/v1/api/feature_flag/local_evaluation?token=%s&send_cohorts=true", this.projectApiKey); + + final Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer " + this.personalApiKey); + headers.put("Content-Type", "application/json"); + headers.put("Accept", "application/json"); + headers.put("User-Agent", "PostHog-Java/1.0.0"); + + final JSONObject jsonResponse = getter.get(url, headers); + if (jsonResponse == null) { + System.err.println("Failed to fetch feature flags: response is null"); + return; + } + + final FeatureFlags featureFlags = FeatureFlagParser.parse(jsonResponse); + this.featureFlags = featureFlags.getFlags(); + this.cohorts = featureFlags.getCohorts(); + this.groups = featureFlags.getGroupTypeMapping(); + } + + /** + * Shuts down the executor service. + */ + public void shutdown() { + executor.shutdownNow(); + } + + /** + * Forces a reload of the feature flags. + */ + public void forceReload() { + this.fetchFeatureFlags(); + } + + /** + * @param config FeatureFlagConfig + * key: String + * distinctId: String + * groupProperties: Map> + * personProperties: Map + * groupProperties and personProperties are optional + * groupProperties is used for cohort matching + * personProperties is used for property matching + * + * @return boolean indicating whether the feature flag is enabled + */ + public boolean isFeatureFlagEnabled(FeatureFlagConfig config) { + if (config.getKey() == null) { + return false; + } + + final Optional featureFlag = getFeatureFlag(config); + return featureFlag.map(flag -> { + try { + return computeFlagLocally(flag, config, this.cohorts).isPresent(); + } catch (InconclusiveMatchException e) { + System.err.println("Error computing flag locally: " + e.getMessage()); + return false; + } + }).orElse(false); + } + + /** + * @param key String + * key of the feature flag + * @param distinctId String + * distinctId of the user + * @return boolean indicating whether the feature flag is enabled + */ + public boolean isFeatureFlagEnabled(String key, String distinctId) { + final FeatureFlagConfig config = new FeatureFlagConfig.Builder(key, distinctId).build(); + final Optional featureFlag = getFeatureFlag(config); + return featureFlag.map(flag -> { + try { + return computeFlagLocally(flag, config, this.cohorts).isPresent(); + } catch (InconclusiveMatchException e) { + System.err.println("Error computing flag locally: " + e.getMessage()); + return false; + } + }).orElse(false); + } + + /** + * @param config FeatureFlagConfig + * key: String + * distinctId: String + * groupProperties: Map> + * personProperties: Map + * groupProperties and personProperties are optional + * groupProperties is used for cohort matching + * personProperties is used for property matching + * + * @return Optional variant key of the feature flag + */ + public Optional getFeatureFlagVariant(FeatureFlagConfig config) { + final Optional featureFlag = getFeatureFlag(config); + return featureFlag.flatMap(flag -> getMatchingVariant(flag, config.getDistinctId()).map(FeatureFlagVariantMeta::getKey)); + } + + /** + * @param config FeatureFlagConfig + * key: String + * distinctId: String + * groupProperties: Map> + * personProperties: Map + * groupProperties and personProperties are optional + * groupProperties is used for cohort matching + * personProperties is used for property matching + * @return Optional feature flag + */ + public Optional getFeatureFlag(FeatureFlagConfig config) { + if (config.getKey() == null) { + return Optional.empty(); + } + + final Optional featureFlag = getFeatureFlags().stream() + .filter(flag -> flag.getKey().equals(config.getKey())) + .findFirst(); + + if (!featureFlag.isPresent()) { + return Optional.empty(); + } + + try { + final Optional computedFlag = computeFlagLocally(featureFlag.get(), config, this.cohorts); + if (computedFlag.isPresent()) { + return featureFlag; + } + } catch (InconclusiveMatchException e) { + System.err.println("Error computing flag locally: " + e.getMessage()); + } + + return Optional.empty(); + } + + /** + * If the feature flags have not been loaded, this method will block until they are loaded. + * + * @return List feature flags + */ + public List getFeatureFlags() { + try { + this.initialLoadLatch.await(); + if (this.featureFlags.isEmpty()) { + System.err.println("No feature flags loaded"); + return new ArrayList<>(); + } + } catch (InterruptedException e) { + System.err.println("Error waiting for initial load: " + e.getMessage()); + } + return featureFlags; + } + + Optional computeFlagLocally( + FeatureFlag flag, + FeatureFlagConfig config, + Map cohorts + ) throws InconclusiveMatchException { + if (flag.isEnsureExperienceContinuity()) { + throw new InconclusiveMatchException("Flag has experience continuity enabled"); + } + + if (!flag.isActive()) { + return Optional.empty(); + } + + final int aggregationIndex = flag.getFilter() + .map(FeatureFlagFilter::getAggregationGroupTypeIndex) + .orElse(0); + + if (aggregationIndex > 0) { + final String groupName = groups.get(String.valueOf(aggregationIndex)); + if (groupName == null) { + throw new InconclusiveMatchException("Flag has unknown group type index"); + } + + final Map> groupProperties = addLocalGroupProperties(config.getGroupProperties(), groups); + return matchFeatureFlagProperties(flag, config.getDistinctId(), groupProperties.get(groupName), cohorts); + } + + final Map personProperties = addLocalPersonProperties(config.getPersonProperties(), config.getDistinctId()); + return matchFeatureFlagProperties(flag, config.getDistinctId(), personProperties, cohorts); + } + + private Map> addLocalGroupProperties(final Map> properties, final Map groups) { + return groups.entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> { + String groupName = entry.getKey(); + Map groupProps = new HashMap<>(); + groupProps.put("$group_key", entry.getValue()); + if (properties.containsKey(groupName)) { + groupProps.putAll(properties.get(groupName)); + } + return groupProps; + } + )); + } + + private Map addLocalPersonProperties(Map properties, String distinctId) { + final Map localProperties = properties.entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue + )); + localProperties.put("distinct_id", distinctId); + return localProperties; + } + + private Optional matchFeatureFlagProperties( + FeatureFlag featureFlag, + String distinctId, + Map properties, + Map cohorts + ) throws InconclusiveMatchException { + + final List conditions = featureFlag.getFilter() + .map(FeatureFlagFilter::getGroups) + .orElse(new ArrayList<>()); + + final List sortedConditions = conditions.stream() + .sorted(Comparator.comparingInt(a -> a.getVariant().isPresent() ? -1 : 1)) + .collect(Collectors.toList()); + + boolean isInconclusive = false; + for (FeatureFlagCondition condition : sortedConditions) { + boolean isMatch; + + try { + isMatch = isConditionMatch(featureFlag, distinctId, condition, properties, cohorts); + } catch (InconclusiveMatchException e) { + isInconclusive = true; + continue; + } + + if (isMatch) { + if (condition.getVariant().isPresent()) { + return condition.getVariant(); + } + + return getMatchingVariant(featureFlag, distinctId) + .map(FeatureFlagVariantMeta::getKey); + } + } + + if (isInconclusive) { + throw new InconclusiveMatchException("Error matching conditions"); + } + + return Optional.empty(); + } + + private boolean isConditionMatch( + FeatureFlag featureFlag, + String distinctId, + FeatureFlagCondition condition, + Map properties, + Map cohorts + ) throws InconclusiveMatchException { + + for (FeatureFlagProperty property : condition.getProperties()) { + boolean matches; + if (property.isCohort()) { + matches = matchCohort(property, properties, cohorts); + } else { + matches = matchProperty(property, properties); + } + + if (!matches) { + return false; + } + } + + return condition.getRolloutPercentage() == 0 || + isSimpleFlagEnabled(featureFlag.getKey(), distinctId, condition.getRolloutPercentage()); + } + + private boolean matchCohort( + FeatureFlagProperty property, + Map properties, + Map cohorts + ) throws InconclusiveMatchException { + + final FeatureFlagPropertyGroup cohort = cohorts.get(property.getKey()); + + if (cohort == null) { + throw new InconclusiveMatchException("Cohort not found"); + } + + return matchPropertyGroup(cohort, properties); + } + + private boolean matchPropertyGroup( + FeatureFlagPropertyGroup featureFlagPropertyGroup, + Map properties + ) throws InconclusiveMatchException { + + if (featureFlagPropertyGroup.getValues().isEmpty()) { + return true; + } + + boolean errorMatchingLocally = false; + + for (Object value : featureFlagPropertyGroup.getValues()) { + boolean matches; + + if (value instanceof FeatureFlagPropertyGroup) { + try { + matches = matchPropertyGroup((FeatureFlagPropertyGroup) value, properties); + } catch (InconclusiveMatchException e) { + errorMatchingLocally = true; + continue; + } + } else { + final FeatureFlagProperty flagProperty = (FeatureFlagProperty) value; + try { + matches = matchProperty(flagProperty, properties); + } catch (InconclusiveMatchException e) { + errorMatchingLocally = true; + continue; + } + } + + final String propertyGroupType = featureFlagPropertyGroup.getType(); + if (propertyGroupType.equals("AND") && !matches) { + return false; + } else if (propertyGroupType.equals("OR") && matches) { + return true; + } + } + + if (errorMatchingLocally) { + throw new InconclusiveMatchException("Error matching property group"); + } + + return featureFlagPropertyGroup.getType().equals("AND"); + } + + private boolean matchProperty( + FeatureFlagProperty property, + Map properties + ) throws InconclusiveMatchException { + final Optional overrideValue = Optional.ofNullable(properties.get(property.getKey())); + final List propertyValue = property.getValue(); + + return propertyValue.stream() + .anyMatch(eachPropertyValue -> + property.getOperator() + .map(operator -> { + switch (operator) { + case EXACT: + final boolean result = overrideValue + .map(value -> value.equals(eachPropertyValue)) + .orElse(false); + return result; + case IS_NOT: + return overrideValue + .map(value -> !value.equals(eachPropertyValue)) + .orElse(false); + case IS_SET: + return overrideValue.isPresent(); + case CONTAINS_INSENSITIVE: + return overrideValue.map(value -> value.toString().toLowerCase()) + .map(value -> value.contains(eachPropertyValue.toLowerCase())) + .orElse(false); + + case NOT_CONTAINS_INSENSITIVE: + return overrideValue.map(value -> value.toString().toLowerCase()) + .map(value -> !value.contains(eachPropertyValue.toLowerCase())) + .orElse(false); + case REGEX: + return overrideValue.map(value -> overrideValue.toString()) + .map(value -> Pattern.compile(eachPropertyValue).matcher(value).find()) + .orElse(false); + case NOT_REGEX: + return overrideValue.map(value -> overrideValue.toString()) + .map(value -> !Pattern.compile(eachPropertyValue).matcher(value).find()) + .orElse(false); + case GREATER_THAN: + return overrideValue.map(value -> Double.parseDouble(value.toString())) + .orElse(0.0) > Double.parseDouble(eachPropertyValue); + case LESS_THAN: + return overrideValue + .map(value -> Double.parseDouble(value.toString())) + .orElse(0.0) < Double.parseDouble(eachPropertyValue); + case GREATER_THAN_OR_EQUAL: + return overrideValue + .map(value -> Double.parseDouble(value.toString())) + .orElse(0.0) >= Double.parseDouble(eachPropertyValue); + case LESS_THAN_OR_EQUAL: + return overrideValue + .map(value -> Double.parseDouble(value.toString())) + .orElse(0.0) <= Double.parseDouble(eachPropertyValue); + } + return false; + }) + .orElse(false)); + } + + private Optional getMatchingVariant(FeatureFlag featureFlag, String distinctId) { + final List lookupTable = getVariantLookupTable(featureFlag); + + double flagHash = Hasher.hash(featureFlag.getKey(), distinctId, "variant"); + + return lookupTable.stream() + .filter(variantMeta -> flagHash >= variantMeta.getValueMin() && flagHash < variantMeta.getValueMax()) + .findFirst(); + } + + private List getVariantLookupTable(FeatureFlag flag) { + final List variants = flag.getFilter() + .flatMap(FeatureFlagFilter::getMultivariate) + .map(FeatureFlagVariants::getVariants) + .orElse(new ArrayList<>()); + + double valueMin = 0.0; + + List lookupTable = new ArrayList<>(); + + for (FeatureFlagVariant variant : variants) { + final double valueMax = valueMin + (double) variant.getRolloutPercentage() / 100; + final FeatureFlagVariantMeta variantMeta = new FeatureFlagVariantMeta.Builder(variant.getKey()) + .valueMin(valueMin) + .valueMax(valueMax) + .build(); + lookupTable.add(variantMeta); + valueMin = valueMax; + } + + return lookupTable; + } + + private boolean isSimpleFlagEnabled(String key, String distinctId, int rolloutPercentage) { + return Hasher.hash(key, distinctId, "") < (double) rolloutPercentage / 100; + } +} diff --git a/posthog/src/main/java/com/posthog/java/Getter.java b/posthog/src/main/java/com/posthog/java/Getter.java new file mode 100644 index 0000000..38e7aed --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/Getter.java @@ -0,0 +1,21 @@ +package com.posthog.java; + +import org.json.JSONObject; + +import java.util.Map; + +/* + * Getter interface for making HTTP GET requests to the PostHog API + */ +interface Getter { + + /* + * Make a GET request to the PostHog API + * + * @param route The route to make the GET request to + * @param headers The headers to include in the GET request + * @return The JSON response from the GET request + */ + JSONObject get(String route, Map headers); + +} diff --git a/posthog/src/main/java/com/posthog/java/HttpSender.java b/posthog/src/main/java/com/posthog/java/HttpInteractor.java similarity index 83% rename from posthog/src/main/java/com/posthog/java/HttpSender.java rename to posthog/src/main/java/com/posthog/java/HttpInteractor.java index 084a2cd..8c7244c 100644 --- a/posthog/src/main/java/com/posthog/java/HttpSender.java +++ b/posthog/src/main/java/com/posthog/java/HttpInteractor.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.time.Duration; import java.util.List; +import java.util.Map; import org.json.JSONException; import org.json.JSONObject; @@ -14,7 +15,7 @@ import okhttp3.RequestBody; import okhttp3.Response; -public class HttpSender implements Sender { +public class HttpInteractor implements Sender, Getter { private String apiKey; private String host; private OkHttpClient client; @@ -53,12 +54,12 @@ public Builder initialRetryInterval(Duration initialRetryInterval) { return this; } - public HttpSender build() { - return new HttpSender(this); + public HttpInteractor build() { + return new HttpInteractor(this); } } - private HttpSender(Builder builder) { + private HttpInteractor(Builder builder) { this.apiKey = builder.apiKey; this.host = builder.host; this.maxRetries = builder.maxRetries; @@ -188,4 +189,35 @@ public JSONObject post(String route, String distinctId) { } return null; } + + @Override + public JSONObject get(String route, Map headers) { + final String url = this.host + route; + final Request.Builder requestBuilder = new Request.Builder() + .url(url); + + if (headers != null) { + headers.forEach(requestBuilder::addHeader); + } + + final Request request = requestBuilder.build(); + + try (final Response response = this.client.newCall(request).execute()) { + if (!response.isSuccessful()) { + System.err.println("Failed to fetch feature flags: " + response.message()); + return null; + } + + if (response.body() == null) { + System.err.println("Failed to fetch feature flags: response body is null"); + return null; + } + + return new JSONObject(response.body().string()); + } catch (IOException e) { + System.err.println("Failed to fetch feature flags: " + e.getMessage()); + } + + return null; + } } diff --git a/posthog/src/main/java/com/posthog/java/InconclusiveMatchException.java b/posthog/src/main/java/com/posthog/java/InconclusiveMatchException.java new file mode 100644 index 0000000..abf6da9 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/InconclusiveMatchException.java @@ -0,0 +1,7 @@ +package com.posthog.java; + +public class InconclusiveMatchException extends Exception { + public InconclusiveMatchException(String message) { + super(message); + } +} diff --git a/posthog/src/main/java/com/posthog/java/LimitedSizeMap.java b/posthog/src/main/java/com/posthog/java/LimitedSizeMap.java new file mode 100644 index 0000000..b608767 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/LimitedSizeMap.java @@ -0,0 +1,26 @@ +package com.posthog.java; + +import java.util.LinkedHashMap; +import java.util.Map; + +class LimitedSizeMap extends LinkedHashMap { + private final int maxSize; + + LimitedSizeMap(int maxSize) { + super(maxSize, 0.75f, false); + this.maxSize = maxSize; + } + + @Override + public V put(K key, V value) { + if (size() >= this.maxSize) { + clear(); + } + return super.put(key, value); + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return false; // We handle the removal logic in the put method + } +} diff --git a/posthog/src/main/java/com/posthog/java/PostHog.java b/posthog/src/main/java/com/posthog/java/PostHog.java index 5b4191f..e4508af 100644 --- a/posthog/src/main/java/com/posthog/java/PostHog.java +++ b/posthog/src/main/java/com/posthog/java/PostHog.java @@ -1,10 +1,11 @@ package com.posthog.java; import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; +import java.util.*; +import java.util.stream.Collectors; +import com.posthog.java.flags.FeatureFlag; +import com.posthog.java.flags.FeatureFlagConfig; import org.json.JSONException; import org.json.JSONObject; @@ -12,21 +13,27 @@ public class PostHog { private QueueManager queueManager; private Thread queueManagerThread; private Sender sender; + private FeatureFlagPoller featureFlagPoller; + + private Map distinctIdsFeatureFlagsReported; private static abstract class BuilderBase { protected QueueManager queueManager; protected Sender sender; + protected Getter getter; + protected FeatureFlagPoller featureFlagPoller; + protected int maxDistinctIdsFeatureFlagsReport = 50_000; } public static class Builder extends BuilderBase { // required - private final String apiKey; - + private final String projectApiKey; // optional private String host = "https://app.posthog.com"; + private String personalApiKey; - public Builder(String apiKey) { - this.apiKey = apiKey; + public Builder(String projectApiKey) { + this.projectApiKey = projectApiKey; } public Builder host(String host) { @@ -34,9 +41,31 @@ public Builder host(String host) { return this; } + public Builder personalApiKey(String personalApiKey) { + this.personalApiKey = personalApiKey; + return this; + } + + public Builder maxDistinctIdsFeatureFlagsReport(int maxDistinctIdsFeatureFlagsReport) { + this.maxDistinctIdsFeatureFlagsReport = maxDistinctIdsFeatureFlagsReport; + return this; + } + public PostHog build() { - this.sender = new HttpSender.Builder(apiKey).host(host).build(); + final HttpInteractor httpClient = new HttpInteractor.Builder(projectApiKey) + .host(host) + .build(); + + this.sender = httpClient; + this.queueManager = new QueueManager.Builder(this.sender).build(); + + if (this.personalApiKey != null && !this.personalApiKey.isEmpty()) { + this.getter = httpClient; + this.featureFlagPoller = new FeatureFlagPoller.Builder(this.projectApiKey, this.personalApiKey, this.getter) + .build(); + } + return new PostHog(this); } } @@ -54,10 +83,42 @@ public PostHog build() { } } + public static class BuilderWithCustomFeatureFlagPoller extends BuilderBase { + + public BuilderWithCustomFeatureFlagPoller(FeatureFlagPoller featureFlagPoller) { + this.featureFlagPoller = featureFlagPoller; + } + + public PostHog build() { + return new PostHog(this); + } + } + + public static class BuilderWithCustomQueueManagerAndCustomFeatureFlagPoller extends BuilderBase { + + public BuilderWithCustomQueueManagerAndCustomFeatureFlagPoller(QueueManager queueManager, FeatureFlagPoller featureFlagPoller, Sender... sender) { + this.queueManager = queueManager; + this.featureFlagPoller = featureFlagPoller; + if (sender.length > 0) + this.sender = sender[0]; + } + + public PostHog build() { + return new PostHog(this); + } + } + private PostHog(BuilderBase builder) { this.queueManager = builder.queueManager; this.sender = builder.sender; + this.featureFlagPoller = builder.featureFlagPoller; + this.distinctIdsFeatureFlagsReported = Collections.synchronizedMap(new LimitedSizeMap<>(builder.maxDistinctIdsFeatureFlagsReport)); + startQueueManager(); + + if (this.featureFlagPoller != null) { + this.featureFlagPoller.poll(); + } } public void shutdown() { @@ -68,6 +129,10 @@ public void shutdown() { // TODO Auto-generated catch block e.printStackTrace(); } + + if (featureFlagPoller != null) { + featureFlagPoller.shutdown(); + } } private void startQueueManager() { @@ -82,7 +147,7 @@ private void enqueue(String distinctId, String event, Map proper } /** - * + * * @param distinctId which uniquely identifies your user in your database. Must * not be null or empty. * @param event name of the event. Must not be null or empty. @@ -93,7 +158,7 @@ public void capture(String distinctId, String event, Map propert } /** - * + * * @param distinctId which uniquely identifies your user in your database. Must * not be null or empty. * @param event name of the event. Must not be null or empty. @@ -102,8 +167,12 @@ public void capture(String distinctId, String event) { enqueue(distinctId, event, null); } + public void capture(String distinctId, String event, boolean sendFeatureFlagEvents) { + + } + /** - * + * * @param distinctId which uniquely identifies your user in your * database. Must not be null or empty. * @param properties an array with any person properties you'd like to @@ -123,7 +192,7 @@ public void identify(String distinctId, Map properties, Map properties) { } /** - * + * * @param distinctId distinct ID to merge. Must not be null or empty. Note: If * there is a conflict, the properties of this person will * take precedence. @@ -152,7 +221,7 @@ public void alias(String distinctId, String alias) { } /** - * + * * @param distinctId which uniquely identifies your user in your database. Must * not be null or empty. * @param properties an array with any person properties you'd like to set. @@ -167,7 +236,7 @@ public void set(String distinctId, Map properties) { } /** - * + * * @param distinctId which uniquely identifies your user in your database. Must * not be null or empty. * @param properties an array with any person properties you'd like to set. @@ -202,40 +271,168 @@ private JSONObject getEventJson(String event, String distinctId, Map> + * personProperties: Map + * groupProperties and personProperties are optional + * groupProperties is used for cohort matching + * personProperties is used for property matching + * key and distinctId must not be null or empty + * + * @return whether the feature flag is enabled or not + */ + public boolean isFeatureFlagEnabled(FeatureFlagConfig config) { + if (this.featureFlagPoller == null || config == null) { + return false; + } + + final boolean isEnabled = this.featureFlagPoller.isFeatureFlagEnabled(config); + if (config.isSendFeatureFlagEvents()) { + enqueueFeatureFlagEvent(config.getKey(), config.getDistinctId(), String.valueOf(isEnabled)); + } + return isEnabled; + } + + /** + * The isFeatureFlagEnabled method is used to determine whether a feature flag is enabled for a given user. + * It will try to use local evaluation first if a personal API key is provided, otherwise it will fallback to the server. + * * @param featureFlag which uniquely identifies your feature flag * * @param distinctId which uniquely identifies your user in your database. Must * not be null or empty. - * + * * @return whether the feature flag is enabled or not */ public boolean isFeatureFlagEnabled(String featureFlag, String distinctId) { - if (getFeatureFlags(distinctId).get(featureFlag) == null) - return false; - return Boolean.parseBoolean(getFeatureFlags(distinctId).get(featureFlag)); + if (this.featureFlagPoller == null) { + if (getFeatureFlags(distinctId).get(featureFlag) == null) + return false; + return Boolean.parseBoolean(getFeatureFlags(distinctId).get(featureFlag)); + } + + final boolean isEnabled = this.featureFlagPoller.isFeatureFlagEnabled(featureFlag, distinctId); + enqueueFeatureFlagEvent(featureFlag, distinctId, String.valueOf(isEnabled)); + return isEnabled; } /** - * + * @deprecated Use {@link #getFeatureFlagVariant(FeatureFlagConfig)} instead + * * @param featureFlag which uniquely identifies your feature flag * * @param distinctId which uniquely identifies your user in your database. Must * not be null or empty. - * + * * @return Variant of the feature flag */ public String getFeatureFlag(String featureFlag, String distinctId) { - return getFeatureFlags(distinctId).get(featureFlag); + if (this.featureFlagPoller == null) { + return getFeatureFlags(distinctId).get(featureFlag); + } + + final FeatureFlagConfig featureFlagConfig = new FeatureFlagConfig.Builder(featureFlag, distinctId).build(); + return this.featureFlagPoller.getFeatureFlagVariant(featureFlagConfig) + .orElse(null); + } + + /** + * The getFeatureFlagVariant method is used to determine the variant of a feature flag for a given user. + * It will try to use local evaluation first if a personal API key is provided, otherwise it will always return an empty Optional. + * + * @param config FeatureFlagConfig + * key: String + * distinctId: String + * groupProperties: Map> + * personProperties: Map + * groupProperties and personProperties are optional + * groupProperties is used for cohort matching + * personProperties is used for property matching + * sendFeatureFlagEvents: boolean + * key and distinctId must not be null or empty + * + * @return Variant of the feature flag + */ + public Optional getFeatureFlagVariant(FeatureFlagConfig config) { + if (this.featureFlagPoller == null || config == null) { + return Optional.empty(); + } + + final Optional variant = this.featureFlagPoller.getFeatureFlagVariant(config); + if (config.isSendFeatureFlagEvents()) { + enqueueFeatureFlagEvent(config.getKey(), config.getDistinctId(), variant.orElse(null)); + } + + return variant; + } + + /** + * The getFeatureFlag method is used to determine the payload of a feature flag for a given user. + * It will try to use local evaluation first if a personal API key is provided, otherwise it will always return an empty Optional. + * + * @param config FeatureFlagConfig + * key: String + * distinctId: String + * groupProperties: Map> + * personProperties: Map + * groupProperties and personProperties are optional + * groupProperties is used for cohort matching + * personProperties is used for property matching + * sendFeatureFlagEvents: boolean + * key and distinctId must not be null or empty + * + * @return FeatureFlag payload + */ + public Optional getFeatureFlag(FeatureFlagConfig config) { + if (this.featureFlagPoller == null || config == null) { + return Optional.empty(); + } + + return this.featureFlagPoller.getFeatureFlag(config); + } + + public Map> getAllFeatureFlags(FeatureFlagConfig config) { + final List localFeatureFlags = this.featureFlagPoller.getFeatureFlags(); + + final Map> results = localFeatureFlags.stream() + .collect(Collectors.toMap( + FeatureFlag::getKey, + featureFlag -> { + try { + return this.featureFlagPoller.computeFlagLocally(featureFlag, config, Collections.emptyMap()); + } catch (InconclusiveMatchException e) { + System.err.println("Inconclusive match for feature flag: " + featureFlag.getKey()); + return Optional.empty(); + } + } + )); + + + return results; + } + + private void enqueueFeatureFlagEvent(String featureFlagKey, String distinctId, String flagValue) { + if (!this.distinctIdsFeatureFlagsReported.containsKey(distinctId)) { + final Map properties = new HashMap<>(); + properties.put("$feature_flag", featureFlagKey); + properties.put("$feature_flag_response", flagValue); + properties.put("$feature_flag_errored", String.valueOf(flagValue == null)); + enqueue(distinctId, "$feature_flag_called", properties); + + this.distinctIdsFeatureFlagsReported.put(distinctId, featureFlagKey); + } } /** - * + * @deprecated Use {@link #getFeatureFlag(FeatureFlagConfig)} instead + * * @param featureFlag which uniquely identifies your feature flag * * @param distinctId which uniquely identifies your user in your database. Must * not be null or empty. - * + * * @return The feature flag payload, if it exists */ public String getFeatureFlagPayload(String featureFlag, String distinctId) { diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlag.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlag.java new file mode 100644 index 0000000..36a6f70 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlag.java @@ -0,0 +1,158 @@ +package com.posthog.java.flags; + +import java.util.Objects; +import java.util.Optional; +import java.util.StringJoiner; + +public class FeatureFlag { + private final String key; + private final String name; + private final int id; + private final int teamId; + private final int rolloutPercentage; + private final boolean isSimpleFlag; + private final boolean active; + private final boolean ensureExperienceContinuity; + private final boolean deleted; + private final FeatureFlagFilter featureFlagFilter; + + public static class Builder { + private final String key; + private final int id; + private final int teamId; + + private int rolloutPercentage; + private String name; + private boolean isSimpleFlag; + private boolean active; + private boolean ensureExperienceContinuity; + private boolean deleted; + private FeatureFlagFilter featureFlagFilter; + + public Builder(String key, int id, int teamId) { + this.key = key; + this.id = id; + this.teamId = teamId; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder rolloutPercentage(int rolloutPercentage) { + this.rolloutPercentage = rolloutPercentage; + return this; + } + + public Builder isSimpleFlag(boolean isSimpleFlag) { + this.isSimpleFlag = isSimpleFlag; + return this; + } + + public Builder active(boolean active) { + this.active = active; + return this; + } + + public Builder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + + public Builder ensureExperienceContinuity(boolean ensureExperienceContinuity) { + this.ensureExperienceContinuity = ensureExperienceContinuity; + return this; + } + + public Builder filter(FeatureFlagFilter featureFlagFilter) { + this.featureFlagFilter = featureFlagFilter; + return this; + } + + public FeatureFlag build() { + return new FeatureFlag(this); + } + } + + private FeatureFlag(Builder builder) { + this.key = builder.key; + this.name = builder.name; + this.id = builder.id; + this.teamId = builder.teamId; + this.rolloutPercentage = builder.rolloutPercentage; + this.isSimpleFlag = builder.isSimpleFlag; + this.active = builder.active; + this.ensureExperienceContinuity = builder.ensureExperienceContinuity; + this.featureFlagFilter = builder.featureFlagFilter; + this.deleted = builder.deleted; + } + + public String getKey() { + return key; + } + + public String getName() { + return name; + } + + public int getId() { + return id; + } + + public int getTeamId() { + return teamId; + } + + public int getRolloutPercentage() { + return rolloutPercentage; + } + + public boolean isSimpleFlag() { + return isSimpleFlag; + } + + public boolean isActive() { + return active; + } + + public boolean isDeleted() { + return deleted; + } + + public boolean isEnsureExperienceContinuity() { + return ensureExperienceContinuity; + } + + public Optional getFilter() { + return Optional.ofNullable(featureFlagFilter); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlag that = (FeatureFlag) o; + return isActive() == that.isActive() && Objects.equals(getKey(), that.getKey()) && Objects.equals(getId(), that.getId()) && Objects.equals(getTeamId(), that.getTeamId()); + } + + @Override + public int hashCode() { + return Objects.hash(getKey(), getId(), getTeamId(), isActive()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlag.class.getSimpleName() + "[", "]") + .add("key='" + key + "'") + .add("name='" + name + "'") + .add("id='" + id + "'") + .add("teamId='" + teamId + "'") + .add("rolloutPercentage=" + rolloutPercentage) + .add("isSimpleFlag=" + isSimpleFlag) + .add("active=" + active) + .add("ensureExperienceContinuity=" + ensureExperienceContinuity) + .add("filters=" + featureFlagFilter) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagCondition.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagCondition.java new file mode 100644 index 0000000..a05a0dd --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagCondition.java @@ -0,0 +1,78 @@ +package com.posthog.java.flags; + +import java.util.*; + +public class FeatureFlagCondition { + private final List properties; + private final int rolloutPercentage; + private final String variant; + + private FeatureFlagCondition(final Builder builder) { + this.properties = builder.properties; + this.rolloutPercentage = builder.rolloutPercentage; + this.variant = builder.variant; + } + + public static class Builder { + private List properties = new ArrayList<>(); + private int rolloutPercentage = 0; + private String variant; + + public Builder properties(List properties) { + this.properties = properties; + return this; + } + + public Builder rolloutPercentage(int rolloutPercentage) { + this.rolloutPercentage = rolloutPercentage; + return this; + } + + public Builder variant(String variant) { + this.variant = variant; + return this; + } + + public FeatureFlagCondition build() { + return new FeatureFlagCondition(this); + } + } + + public List getProperties() { + return properties; + } + + public int getRolloutPercentage() { + return rolloutPercentage; + } + + public Optional getVariant() { + if (this.variant.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(variant); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlagCondition that = (FeatureFlagCondition) o; + return getRolloutPercentage() == that.getRolloutPercentage() && Objects.equals(getProperties(), that.getProperties()) && Objects.equals(getVariant(), that.getVariant()); + } + + @Override + public int hashCode() { + return Objects.hash(getProperties(), getRolloutPercentage(), getVariant()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlagCondition.class.getSimpleName() + "[", "]") + .add("properties=" + properties) + .add("rolloutPercentage=" + rolloutPercentage) + .add("variant='" + variant + "'") + .toString(); + } +} \ No newline at end of file diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagConfig.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagConfig.java new file mode 100644 index 0000000..3583ac4 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagConfig.java @@ -0,0 +1,116 @@ +package com.posthog.java.flags; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.StringJoiner; + +public class FeatureFlagConfig { + private final String key; + private final String distinctId; + private final Map groups; + private final Map personProperties; + private final Map> groupProperties; + private final boolean sendFeatureFlagEvents; + + private FeatureFlagConfig(Builder builder) { + this.key = builder.key; + this.distinctId = builder.distinctId; + this.groups = builder.groups; + this.personProperties = builder.personProperties; + this.groupProperties = builder.groupProperties; + this.sendFeatureFlagEvents = builder.sendFeatureFlagEvents; + } + + public static class Builder { + private final String key; + private final String distinctId; + private Map groups = new HashMap<>(); + private Map personProperties = new HashMap<>(); + private Map> groupProperties = new HashMap<>(); + private boolean sendFeatureFlagEvents = true; + + public Builder(String distinctId) { + this.key = null; + this.distinctId = distinctId; + } + + public Builder(String key, String distinctId) { + this.key = key; + this.distinctId = distinctId; + } + + public Builder groups(Map groups) { + this.groups = groups; + return this; + } + + public Builder personProperties(Map personProperties) { + this.personProperties = personProperties; + return this; + } + + public Builder groupProperties(Map> groupProperties) { + this.groupProperties = groupProperties; + return this; + } + + public Builder sendFeatureFlagEvents(boolean sendFeatureFlagEvents) { + this.sendFeatureFlagEvents = sendFeatureFlagEvents; + return this; + } + + public FeatureFlagConfig build() { + return new FeatureFlagConfig(this); + } + } + + public String getKey() { + return key; + } + + public String getDistinctId() { + return distinctId; + } + + public Map getGroups() { + return groups; + } + + public Map getPersonProperties() { + return personProperties; + } + + public Map> getGroupProperties() { + return groupProperties; + } + + public boolean isSendFeatureFlagEvents() { + return sendFeatureFlagEvents; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlagConfig that = (FeatureFlagConfig) o; + return sendFeatureFlagEvents == that.sendFeatureFlagEvents && Objects.equals(key, that.key) && Objects.equals(distinctId, that.distinctId) && Objects.equals(groups, that.groups) && Objects.equals(personProperties, that.personProperties) && Objects.equals(groupProperties, that.groupProperties); + } + + @Override + public int hashCode() { + return Objects.hash(key, distinctId, groups, personProperties, groupProperties, sendFeatureFlagEvents); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlagConfig.class.getSimpleName() + "[", "]") + .add("key='" + key + "'") + .add("distinctId='" + distinctId + "'") + .add("groups=" + groups) + .add("personProperties=" + personProperties) + .add("groupProperties=" + groupProperties) + .add("sendFeatureFlagEvents=" + sendFeatureFlagEvents) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagFilter.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagFilter.java new file mode 100644 index 0000000..a58759a --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagFilter.java @@ -0,0 +1,87 @@ +package com.posthog.java.flags; + +import java.util.*; + +public class FeatureFlagFilter { + private final int aggregationGroupTypeIndex; + private final List groups; + private final FeatureFlagVariants multivariate; + private final Map payloads; + + private FeatureFlagFilter(Builder builder) { + this.aggregationGroupTypeIndex = builder.aggregationGroupTypeIndex; + this.groups = builder.groups; + this.multivariate = builder.multivariate; + this.payloads = builder.payloads; + } + + public static class Builder { + private int aggregationGroupTypeIndex = 0; + private List groups = new ArrayList<>(); + private FeatureFlagVariants multivariate = null; + private Map payloads = new HashMap<>(); + + public Builder aggregationGroupTypeIndex(int aggregationGroupTypeIndex) { + this.aggregationGroupTypeIndex = aggregationGroupTypeIndex; + return this; + } + + public Builder groups(List groups) { + this.groups = groups; + return this; + } + + public Builder multivariate(FeatureFlagVariants multivariate) { + this.multivariate = multivariate; + return this; + } + + public Builder payloads(Map payloads) { + this.payloads = payloads; + return this; + } + + public FeatureFlagFilter build() { + return new FeatureFlagFilter(this); + } + } + + public int getAggregationGroupTypeIndex() { + return aggregationGroupTypeIndex; + } + + public List getGroups() { + return groups; + } + + public Optional getMultivariate() { + return Optional.ofNullable(multivariate); + } + + public Map getPayloads() { + return payloads; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlagFilter featureFlagFilter = (FeatureFlagFilter) o; + return getAggregationGroupTypeIndex() == featureFlagFilter.getAggregationGroupTypeIndex() && Objects.equals(getGroups(), featureFlagFilter.getGroups()) && Objects.equals(getMultivariate(), featureFlagFilter.getMultivariate()); + } + + @Override + public int hashCode() { + return Objects.hash(getAggregationGroupTypeIndex(), getGroups(), getMultivariate()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlagFilter.class.getSimpleName() + "[", "]") + .add("aggregationGroupTypeIndex=" + aggregationGroupTypeIndex) + .add("groups=" + groups) + .add("multivariate=" + multivariate) + .add("payloads=" + payloads) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagParser.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagParser.java new file mode 100644 index 0000000..f662122 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagParser.java @@ -0,0 +1,158 @@ +package com.posthog.java.flags; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class FeatureFlagParser { + + public static FeatureFlags parse(JSONObject responseRaw) { + final List flags = StreamSupport.stream(responseRaw.optJSONArray("flags").spliterator(), false) + .map(JSONObject.class::cast) + .map(FeatureFlagParser::parseFeatureFlag) + .collect(Collectors.toList()); + + return new FeatureFlags.Builder() + .flags(flags) + .groupTypeMapping(parseGroupTypeMapping(responseRaw.optJSONObject("group_type_mapping"))) + .cohorts(parseCohorts(responseRaw.optJSONObject("cohorts"))) + .build(); + } + + private static FeatureFlag parseFeatureFlag(JSONObject featureFlagRaw) throws JSONException { + if (featureFlagRaw == null) + return null; + + final String key = featureFlagRaw.getString("key"); + final int id = featureFlagRaw.getInt("id"); + final int teamId = featureFlagRaw.getInt("team_id"); + + return new FeatureFlag.Builder(key, id, teamId) + .isSimpleFlag(featureFlagRaw.optBoolean("is_simple_flag")) + .rolloutPercentage(featureFlagRaw.optInt("rollout_percentage")) + .active(featureFlagRaw.optBoolean("active")) + .filter(parseFilter(featureFlagRaw.optJSONObject("filters"))) + .ensureExperienceContinuity(featureFlagRaw.optBoolean("ensure_experience_continuity")) + .build(); + } + + private static FeatureFlagFilter parseFilter(JSONObject filterRaw) throws JSONException { + if (filterRaw == null) + return null; + + return new FeatureFlagFilter.Builder() + .aggregationGroupTypeIndex(filterRaw.optInt("aggregation_group_type_index")) + .groups(parseFeatureFlagConditions(filterRaw.optJSONArray("groups"))) + .multivariate(parseVariants(filterRaw.optJSONObject("multivariate"))) + .payloads(filterRaw.optJSONObject("payloads").toMap()) + .build(); + } + + private static List parseFeatureFlagConditions(JSONArray conditionsRaw) throws JSONException { + if (conditionsRaw == null) + return new ArrayList<>(); + + return StreamSupport.stream(conditionsRaw.spliterator(), false) + .map(JSONObject.class::cast) + .map(conditionRaw -> { + final List properties = StreamSupport.stream(conditionRaw.getJSONArray("properties").spliterator(), false) + .map(JSONObject.class::cast) + .map(FeatureFlagParser::parseFlagProperty) + .collect(Collectors.toList()); + return parseFeatureFlagCondition(properties, conditionRaw); + }) + .collect(Collectors.toList()); + } + + private static FeatureFlagProperty parseFlagProperty(JSONObject flagPropertyRaw) throws JSONException { + if (flagPropertyRaw == null) + return null; + + final List value = flagPropertyRaw.optJSONArray("value") != null + ? StreamSupport.stream(flagPropertyRaw.getJSONArray("value").spliterator(), false) + .map(Object::toString) + .collect(Collectors.toList()) + : Collections.singletonList(flagPropertyRaw.optString("value")); + + return new FeatureFlagProperty.Builder(flagPropertyRaw.optString("key")) + .negation(flagPropertyRaw.optBoolean("negation", false)) + .value(value) + .operator(flagPropertyRaw.optString("operator")) + .type(flagPropertyRaw.optString("type")) + .build(); + } + + private static FeatureFlagCondition parseFeatureFlagCondition(final List properties, JSONObject conditionRaw) throws JSONException { + if (conditionRaw == null) + return null; + + return new FeatureFlagCondition.Builder() + .properties(properties) + .rolloutPercentage(conditionRaw.optInt("rollout_percentage")) + .variant(conditionRaw.optString("variant")) + .build(); + } + + private static FeatureFlagVariants parseVariants(JSONObject variantsRaw) throws JSONException { + if (variantsRaw == null) + return null; + + return StreamSupport.stream(variantsRaw.getJSONArray("variants").spliterator(), false) + .map(JSONObject.class::cast) + .map(FeatureFlagParser::parseFlagVariant) + .collect(Collectors.collectingAndThen(Collectors.toList(), variants -> new FeatureFlagVariants.Builder().variants(variants).build())); + } + + private static FeatureFlagVariant parseFlagVariant(JSONObject flagVariantRaw) throws JSONException { + if (flagVariantRaw == null) + return null; + + return new FeatureFlagVariant.Builder(flagVariantRaw.getString("key"), flagVariantRaw.getString("name")) + .rolloutPercentage(flagVariantRaw.optInt("rollout_percentage")) + .build(); + } + + private static Map parseCohorts(JSONObject cohortsRaw) throws JSONException { + if (cohortsRaw == null) + return new HashMap<>(); + + return cohortsRaw.keySet() + .stream() + .collect(Collectors.toMap(key -> key, key -> parsePropertyGroup(cohortsRaw.getJSONObject(key)))); + } + + private static FeatureFlagPropertyGroup parsePropertyGroup(JSONObject propertyGroupRaw) throws JSONException { + final List values = new ArrayList<>(); + final JSONArray valuesJson = propertyGroupRaw.getJSONArray("values"); + + for (int i = 0; i < valuesJson.length(); i++) { + Object value = valuesJson.get(i); + if (value instanceof JSONObject) { + final JSONObject possibleChild = (JSONObject) value; + if (possibleChild.has("type")) { + values.add(parsePropertyGroup(possibleChild)); + } + } else { + values.add(value); + } + } + + return new FeatureFlagPropertyGroup.Builder() + .type(propertyGroupRaw.optString("type")) + .values(values) + .build(); + } + + private static Map parseGroupTypeMapping(JSONObject groupTypeMappingRaw) throws JSONException { + if (groupTypeMappingRaw == null) + return new HashMap<>(); + + return groupTypeMappingRaw.keySet() + .stream() + .collect(Collectors.toMap(key -> key, groupTypeMappingRaw::getString)); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagProperty.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagProperty.java new file mode 100644 index 0000000..3cc46c5 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagProperty.java @@ -0,0 +1,105 @@ +package com.posthog.java.flags; + +import java.util.*; + +public class FeatureFlagProperty { + + private final String key; + private final String operator; + private final List value; + private final String type; + private final boolean negation; + + private FeatureFlagProperty(final Builder builder) { + this.key = builder.key; + this.operator = builder.operator; + this.value = builder.value; + this.type = builder.type; + this.negation = builder.negation; + } + + public static class Builder { + private final String key; + + private String operator; + private String type; + private List value = new ArrayList<>(); + private boolean negation = false; + + public Builder(String key) { + this.key = key; + } + + public Builder operator(String operator) { + this.operator = operator; + return this; + } + + public Builder value(List value) { + this.value = value; + return this; + } + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder negation(boolean negation) { + this.negation = negation; + return this; + } + + public FeatureFlagProperty build() { + return new FeatureFlagProperty(this); + } + } + + public String getKey() { + return key; + } + + public Optional getOperator() { + return Optional.of(FeatureFlagPropertyOperator.fromString(operator)); + } + + public List getValue() { + return value; + } + + public Optional getType() { + return Optional.ofNullable(type); + } + + public boolean isNegation() { + return negation; + } + + public boolean isCohort() { + return type.equals("cohort"); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlagProperty that = (FeatureFlagProperty) o; + return Objects.equals(getKey(), that.getKey()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getKey()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlagProperty.class.getSimpleName() + "[", "]") + .add("key='" + key + "'") + .add("operator='" + operator + "'") + .add("value=" + value) + .add("type='" + type + "'") + .add("negation=" + negation) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagPropertyGroup.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagPropertyGroup.java new file mode 100644 index 0000000..f637443 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagPropertyGroup.java @@ -0,0 +1,63 @@ +package com.posthog.java.flags; + +import java.util.List; +import java.util.Objects; +import java.util.StringJoiner; + +public class FeatureFlagPropertyGroup { + private final String type; + private final List values; + + private FeatureFlagPropertyGroup(final Builder builder) { + this.type = builder.type; + this.values = builder.values; + } + + public static class Builder { + private String type; + private List values; + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder values(List values) { + this.values = values; + return this; + } + + public FeatureFlagPropertyGroup build() { + return new FeatureFlagPropertyGroup(this); + } + } + + public String getType() { + return type; + } + + public List getValues() { + return values; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlagPropertyGroup that = (FeatureFlagPropertyGroup) o; + return Objects.equals(getType(), that.getType()) && Objects.equals(getValues(), that.getValues()); + } + + @Override + public int hashCode() { + return Objects.hash(getType(), getValues()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlagPropertyGroup.class.getSimpleName() + "[", "]") + .add("type='" + type + "'") + .add("values=" + values) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagPropertyOperator.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagPropertyOperator.java new file mode 100644 index 0000000..437a6db --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagPropertyOperator.java @@ -0,0 +1,34 @@ +package com.posthog.java.flags; + +public enum FeatureFlagPropertyOperator { + EXACT("exact"), + IS_NOT("is_not"), + IS_SET("is_set"), + CONTAINS_INSENSITIVE("icontains"), + NOT_CONTAINS_INSENSITIVE("not_icontains"), + REGEX("regex"), + NOT_REGEX("not_regex"), + GREATER_THAN("gt"), + GREATER_THAN_OR_EQUAL("gte"), + LESS_THAN("lt"), + LESS_THAN_OR_EQUAL("lte"); + + private final String operator; + + FeatureFlagPropertyOperator(String operator) { + this.operator = operator; + } + + public String getOperator() { + return operator; + } + + public static FeatureFlagPropertyOperator fromString(String operator) { + for (FeatureFlagPropertyOperator op : FeatureFlagPropertyOperator.values()) { + if (op.getOperator().equalsIgnoreCase(operator)) { + return op; + } + } + throw new IllegalArgumentException("No enum constant with operator: " + operator); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariant.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariant.java new file mode 100644 index 0000000..7c6b767 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariant.java @@ -0,0 +1,71 @@ +package com.posthog.java.flags; + +import java.util.Objects; +import java.util.StringJoiner; + +public class FeatureFlagVariant { + private final String key; + private final String name; + private final int rolloutPercentage; + + private FeatureFlagVariant(Builder builder) { + this.key = builder.key; + this.name = builder.name; + this.rolloutPercentage = builder.rolloutPercentage; + } + + public static class Builder { + private final String key; + private final String name; + + private int rolloutPercentage = 0; + + public Builder(String key, String name) { + this.key = key; + this.name = name; + } + + public Builder rolloutPercentage(int rolloutPercentage) { + this.rolloutPercentage = rolloutPercentage; + return this; + } + + public FeatureFlagVariant build() { + return new FeatureFlagVariant(this); + } + } + + public String getKey() { + return key; + } + + public String getName() { + return name; + } + + public int getRolloutPercentage() { + return rolloutPercentage; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlagVariant that = (FeatureFlagVariant) o; + return Objects.equals(getKey(), that.getKey()) && Objects.equals(getName(), that.getName()); + } + + @Override + public int hashCode() { + return Objects.hash(getKey(), getName()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlagVariant.class.getSimpleName() + "[", "]") + .add("key='" + key + "'") + .add("name='" + name + "'") + .add("rolloutPercentage=" + rolloutPercentage) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariantMeta.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariantMeta.java new file mode 100644 index 0000000..4f7d5f1 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariantMeta.java @@ -0,0 +1,75 @@ +package com.posthog.java.flags; + +import java.util.Objects; +import java.util.StringJoiner; + +public class FeatureFlagVariantMeta { + public final String key; + public final double valueMin; + public final double valueMax; + + private FeatureFlagVariantMeta(Builder builder) { + this.key = builder.key; + this.valueMin = builder.valueMin; + this.valueMax = builder.valueMax; + } + + public static class Builder { + private final String key; + + private double valueMin = 0; + private double valueMax = 0; + + public Builder(String key) { + this.key = key; + } + + public Builder valueMin(double valueMin) { + this.valueMin = valueMin; + return this; + } + + public Builder valueMax(double valueMax) { + this.valueMax = valueMax; + return this; + } + + public FeatureFlagVariantMeta build() { + return new FeatureFlagVariantMeta(this); + } + } + + public String getKey() { + return key; + } + + public double getValueMin() { + return valueMin; + } + + public double getValueMax() { + return valueMax; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlagVariantMeta that = (FeatureFlagVariantMeta) o; + return Objects.equals(getKey(), that.getKey()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getKey()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlagVariantMeta.class.getSimpleName() + "[", "]") + .add("key='" + key + "'") + .add("valueMin=" + valueMin) + .add("valueMax=" + valueMax) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariants.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariants.java new file mode 100644 index 0000000..47a632e --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlagVariants.java @@ -0,0 +1,52 @@ +package com.posthog.java.flags; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.StringJoiner; + +public class FeatureFlagVariants { + + private final List variants; + + private FeatureFlagVariants(final Builder builder) { + this.variants = builder.variants; + } + + public static class Builder { + private List variants = new ArrayList<>(); + + public Builder variants(List variants) { + this.variants = variants; + return this; + } + + public FeatureFlagVariants build() { + return new FeatureFlagVariants(this); + } + } + + public List getVariants() { + return variants; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlagVariants featureFlagVariants1 = (FeatureFlagVariants) o; + return Objects.equals(getVariants(), featureFlagVariants1.getVariants()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getVariants()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlagVariants.class.getSimpleName() + "[", "]") + .add("variants=" + variants) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/FeatureFlags.java b/posthog/src/main/java/com/posthog/java/flags/FeatureFlags.java new file mode 100644 index 0000000..f3b7464 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/FeatureFlags.java @@ -0,0 +1,74 @@ +package com.posthog.java.flags; + +import java.util.*; + +public class FeatureFlags { + private final List flags; + private final Map groupTypeMapping; + private final Map cohorts; + + private FeatureFlags(final Builder builder) { + this.flags = builder.flags; + this.groupTypeMapping = builder.groupTypeMapping; + this.cohorts = builder.cohorts; + } + + public static class Builder { + private List flags = new ArrayList<>(); + private Map groupTypeMapping = new HashMap<>(); + private Map cohorts = new HashMap<>(); + + public Builder flags(List flags) { + this.flags = flags; + return this; + } + + public Builder groupTypeMapping(Map groupTypeMapping) { + this.groupTypeMapping = groupTypeMapping; + return this; + } + + public Builder cohorts(Map cohorts) { + this.cohorts = cohorts; + return this; + } + + public FeatureFlags build() { + return new FeatureFlags(this); + } + } + + public List getFlags() { + return flags; + } + + public Map getGroupTypeMapping() { + return groupTypeMapping; + } + + public Map getCohorts() { + return cohorts; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlags that = (FeatureFlags) o; + return Objects.equals(getFlags(), that.getFlags()) && Objects.equals(getGroupTypeMapping(), that.getGroupTypeMapping()) && Objects.equals(getCohorts(), that.getCohorts()); + } + + @Override + public int hashCode() { + return Objects.hash(getFlags(), getGroupTypeMapping(), getCohorts()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FeatureFlags.class.getSimpleName() + "[", "]") + .add("flags=" + flags) + .add("groupTypeMapping=" + groupTypeMapping) + .add("cohorts=" + cohorts) + .toString(); + } +} diff --git a/posthog/src/main/java/com/posthog/java/flags/hash/Hasher.java b/posthog/src/main/java/com/posthog/java/flags/hash/Hasher.java new file mode 100644 index 0000000..acc4f78 --- /dev/null +++ b/posthog/src/main/java/com/posthog/java/flags/hash/Hasher.java @@ -0,0 +1,30 @@ +package com.posthog.java.flags.hash; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class Hasher { + private static final long LONG_SCALE = 0xfffffffffffffffL; + + public static double hash(String key, String distinctId, String salt) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + digest.update((key + "." + distinctId + salt).getBytes(StandardCharsets.UTF_8)); + byte[] hash = digest.digest(); + String hexString = bytesToHex(hash).substring(0, 15); + long value = Long.parseLong(hexString, 16); + return (double) value / LONG_SCALE; + } catch (NoSuchAlgorithmException | NumberFormatException e) { + throw new RuntimeException("Hashing error: " + e.getMessage(), e); + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/posthog/src/test/java/com/posthog/java/FeatureFlagPollerTest.java b/posthog/src/test/java/com/posthog/java/FeatureFlagPollerTest.java new file mode 100644 index 0000000..276958e --- /dev/null +++ b/posthog/src/test/java/com/posthog/java/FeatureFlagPollerTest.java @@ -0,0 +1,242 @@ +package com.posthog.java; + +import com.posthog.java.TestGetter; +import com.posthog.java.flags.FeatureFlag; +import com.posthog.java.flags.FeatureFlagConfig; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.*; + +public class FeatureFlagPollerTest { + + private TestGetter testGetter; + private FeatureFlagPoller sut; + + @Before + public void setUp() { + testGetter = new TestGetter(); + sut = new FeatureFlagPoller.Builder("", "", testGetter) + .build(); + + sut.poll(); + } + + @Test + public void shouldRetrieveAllFlags() { + final List flags = sut.getFeatureFlags(); + assertEquals(1, flags.size()); + assertEquals("java-feature-flag", flags.get(0).getKey()); + assertEquals(1000, flags.get(0).getId()); + assertEquals(20000, flags.get(0).getTeamId()); + } + + @Test + public void shouldReturnTrueWhenFeatureFlagIsEnabledForUser() { + FeatureFlagConfig config = new FeatureFlagConfig.Builder("java-feature-flag", "id-1") + .build(); + + final boolean enabled = sut.isFeatureFlagEnabled(config); + assertTrue(enabled); + } + + @Test + public void shouldReturnFalseWhenFeatureFlagIsDisabledForUser() { + FeatureFlagConfig config = new FeatureFlagConfig.Builder("java-feature-flag", "some-id") + .build(); + + final boolean enabled = sut.isFeatureFlagEnabled(config); + assertFalse(enabled); + } + + @Test + public void shouldReturnFeatureFlagVariant() { + FeatureFlagConfig config = new FeatureFlagConfig.Builder("java-feature-flag", "id-1") + .build(); + + final Optional variant = sut.getFeatureFlagVariant(config); + assertTrue(variant.isPresent()); + } + + @Test + public void shouldBeAbleToReturnTheFullFeatureFlag() { + FeatureFlagConfig config = new FeatureFlagConfig.Builder("java-feature-flag", "id-1") + .build(); + + final Optional flag = sut.getFeatureFlag(config); + assertTrue(flag.isPresent()); + assertEquals("java-feature-flag", flag.get().getKey()); + assertEquals(1000, flag.get().getId()); + assertEquals(20000, flag.get().getTeamId()); + } + + @Test + public void reloadFeatureFlags() { + final List flags = sut.getFeatureFlags(); + assertEquals(1, flags.size()); + assertEquals("java-feature-flag", flags.get(0).getKey()); + assertEquals(1000, flags.get(0).getId()); + assertEquals(20000, flags.get(0).getTeamId()); + + + testGetter.setJsonString( + "{\n" + + " \"flags\": [\n" + + " {\n" + + " \"id\": 1000,\n" + + " \"team_id\": 20000,\n" + + " \"name\": \"\",\n" + + " \"key\": \"java-feature-flag\",\n" + + " \"filters\": {\n" + + " \"groups\": [\n" + + " {\n" + + " \"variant\": \"variant-2\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"id\",\n" + + " \"type\": \"cohort\",\n" + + " \"value\": 17231\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 39\n" + + " },\n" + + " {\n" + + " \"variant\": null,\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": [\n" + + " \"id-1\"\n" + + " ],\n" + + " \"operator\": \"exact\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 100\n" + + " },\n" + + " {\n" + + " \"variant\": \"variant-2\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": \"a-value\",\n" + + " \"operator\": \"icontains\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 41\n" + + " }\n" + + " ],\n" + + " \"payloads\": {\n" + + " \"variant-1\": \"{\\\"something\\\": 1}\",\n" + + " \"variant-2\": \"1\"\n" + + " },\n" + + " \"multivariate\": {\n" + + " \"variants\": [\n" + + " {\n" + + " \"key\": \"variant-1\",\n" + + " \"name\": \"\",\n" + + " \"rollout_percentage\": 100\n" + + " },\n" + + " {\n" + + " \"key\": \"variant-2\",\n" + + " \"name\": \"with description\",\n" + + " \"rollout_percentage\": 0\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"deleted\": false,\n" + + " \"active\": true,\n" + + " \"ensure_experience_continuity\": false\n" + + " },\n" + + " {\n" + + " \"id\": 1001,\n" + + " \"team_id\": 20000,\n" + + " \"name\": \"\",\n" + + " \"key\": \"java-feature-flag-2\",\n" + + " \"filters\": {\n" + + " \"groups\": [\n" + + " {\n" + + " \"variant\": \"variant-2\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"id\",\n" + + " \"type\": \"cohort\",\n" + + " \"value\": 17231\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 39\n" + + " },\n" + + " {\n" + + " \"variant\": null,\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": [\n" + + " \"id-1\"\n" + + " ],\n" + + " \"operator\": \"exact\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 100\n" + + " },\n" + + " {\n" + + " \"variant\": \"variant-2\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": \"a-value\",\n" + + " \"operator\": \"icontains\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 41\n" + + " }\n" + + " ],\n" + + " \"payloads\": {\n" + + " \"variant-1\": \"{\\\"something\\\": 1}\",\n" + + " \"variant-2\": \"1\"\n" + + " },\n" + + " \"multivariate\": {\n" + + " \"variants\": [\n" + + " {\n" + + " \"key\": \"variant-1\",\n" + + " \"name\": \"\",\n" + + " \"rollout_percentage\": 100\n" + + " },\n" + + " {\n" + + " \"key\": \"variant-2\",\n" + + " \"name\": \"with description\",\n" + + " \"rollout_percentage\": 0\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"deleted\": false,\n" + + " \"active\": true,\n" + + " \"ensure_experience_continuity\": false\n" + + " }\n" + + " ],\n" + + " \"group_type_mapping\": {},\n" + + " \"cohorts\": {}\n" + + "}" + ); + + sut.forceReload(); + + final List flags2 = sut.getFeatureFlags(); + assertEquals(2, flags2.size()); + assertEquals("java-feature-flag", flags2.get(0).getKey()); + assertEquals(1000, flags2.get(0).getId()); + assertEquals(20000, flags2.get(0).getTeamId()); + assertEquals("java-feature-flag-2", flags2.get(1).getKey()); + assertEquals(1001, flags2.get(1).getId()); + assertEquals(20000, flags2.get(1).getTeamId()); + } + +} diff --git a/posthog/src/test/java/com/posthog/java/HttpSenderTest.java b/posthog/src/test/java/com/posthog/java/HttpInteractorTest.java similarity index 97% rename from posthog/src/test/java/com/posthog/java/HttpSenderTest.java rename to posthog/src/test/java/com/posthog/java/HttpInteractorTest.java index 1337290..08801b1 100644 --- a/posthog/src/test/java/com/posthog/java/HttpSenderTest.java +++ b/posthog/src/test/java/com/posthog/java/HttpInteractorTest.java @@ -19,10 +19,10 @@ import okhttp3.mockwebserver.RecordedRequest; import okhttp3.mockwebserver.SocketPolicy; -public class HttpSenderTest { +public class HttpInteractorTest { public MockWebServer mockWebServer; - private HttpSender sender; + private HttpInteractor sender; private String apiKey = "UNIT_TESTING_API_KEY"; @Before @@ -32,7 +32,7 @@ public void setUp() throws IOException { String httpUrl = mockWebServer.url("").toString(); String host = httpUrl.substring(0, httpUrl.length() - 1); // strip trailing / - sender = new HttpSender.Builder(apiKey).host(host).maxRetries(1).build(); + sender = new HttpInteractor.Builder(apiKey).host(host).maxRetries(1).build(); } @After diff --git a/posthog/src/test/java/com/posthog/java/PostHogTest.java b/posthog/src/test/java/com/posthog/java/PostHogTest.java index daa82a6..84f8cd1 100644 --- a/posthog/src/test/java/com/posthog/java/PostHogTest.java +++ b/posthog/src/test/java/com/posthog/java/PostHogTest.java @@ -1,15 +1,18 @@ package com.posthog.java; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.junit.Assert.*; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import com.posthog.java.flags.FeatureFlag; +import com.posthog.java.flags.FeatureFlagConfig; import org.json.JSONObject; import org.junit.Before; import org.junit.Ignore; @@ -21,6 +24,7 @@ public class PostHogTest { private PostHog ph; private TestSender sender; + private TestGetter getter; private QueueManager queueManager; private String instantExpected = "2020-02-02T02:02:02Z"; private Clock clock; @@ -42,9 +46,14 @@ public Instant now() { sender = new TestSender(); // by default not sleeping just dependent on queue size of 1, i.e. each call // separately - queueManager = new QueueManager.Builder(sender).sleepMs(0).maxTimeInQueue(Duration.ofDays(5)).maxQueueSize(1) + queueManager = new QueueManager.Builder(sender) + .sleepMs(0) + .maxTimeInQueue(Duration.ofDays(5)) + .maxQueueSize(1) + .build(); + + ph = new PostHog.BuilderWithCustomQueueManager(queueManager, sender) .build(); - ph = new PostHog.BuilderWithCustomQueueManager(queueManager, sender).build(); } @Test @@ -287,7 +296,92 @@ public void testMaxTimeInQueue() throws InterruptedException { assertThatJson( "{\"distinct_id\":\"id6\",\"event\":\"second batch event\",\"timestamp\":\"" + thirdInstant + "\"}") .isEqualTo(new JSONObject(json, "distinct_id", "event", "timestamp").toString()); + } + + @Test + public void testIsFeatureFlagEnabledLocalNoConfig() { + final FeatureFlagPoller featureFlagPoller = new FeatureFlagPoller.Builder("", "", new TestGetter()).build(); + ph = new PostHog.BuilderWithCustomFeatureFlagPoller(featureFlagPoller) + .build(); + + final boolean isEnabled = ph.isFeatureFlagEnabled("java-feature-flag", "id-1"); + assertTrue(isEnabled); + } + + @Test + public void testIsFeatureFlagEnabledLocal() { + final FeatureFlagPoller featureFlagPoller = new FeatureFlagPoller.Builder("", "", new TestGetter()).build(); + ph = new PostHog.BuilderWithCustomFeatureFlagPoller(featureFlagPoller) + .build(); + + final FeatureFlagConfig featureFlagConfig = new FeatureFlagConfig.Builder("java-feature-flag", "id-1").build(); + final boolean isEnabled = ph.isFeatureFlagEnabled(featureFlagConfig); + assertTrue(isEnabled); + + final boolean isEnabled2 = ph.isFeatureFlagEnabled("java-feature-flag", "some-user"); + assertFalse(isEnabled2); + } + + @Test + public void testFeatureFlagRetrieveVariant() { + final FeatureFlagPoller featureFlagPoller = new FeatureFlagPoller.Builder("", "", new TestGetter()).build(); + ph = new PostHog.BuilderWithCustomFeatureFlagPoller(featureFlagPoller) + .build(); + + final FeatureFlagConfig featureFlagConfig = new FeatureFlagConfig.Builder("java-feature-flag", "id-1").build(); + final Optional variant = ph.getFeatureFlagVariant(featureFlagConfig); + assertTrue(variant.isPresent()); + } + + @Test + public void restFeatureFlagRetrieveFlag() { + final FeatureFlagPoller featureFlagPoller = new FeatureFlagPoller.Builder("", "", new TestGetter()).build(); + ph = new PostHog.BuilderWithCustomFeatureFlagPoller(featureFlagPoller) + .build(); + + final FeatureFlagConfig featureFlagConfig = new FeatureFlagConfig.Builder("java-feature-flag", "id-1").build(); + final Optional flag = ph.getFeatureFlag(featureFlagConfig); + assertTrue(flag.isPresent()); + assertEquals("java-feature-flag", flag.get().getKey()); + } + + @Test + public void testFeatureFlagEvents() { + final FeatureFlagPoller featureFlagPoller = new FeatureFlagPoller.Builder("", "", new TestGetter()).build(); + ph = new PostHog.BuilderWithCustomQueueManagerAndCustomFeatureFlagPoller(queueManager, featureFlagPoller) + .build(); + + final FeatureFlagConfig featureFlagConfig = new FeatureFlagConfig.Builder("java-feature-flag", "id-1") + .sendFeatureFlagEvents(true) + .build(); + + final boolean isEnabled = ph.isFeatureFlagEnabled(featureFlagConfig); + assertTrue(isEnabled); + + ph.shutdown(); + assertEquals(1, sender.calls.size()); + assertEquals(1, sender.calls.get(0).size()); + JSONObject json = sender.calls.get(0).get(0); + assertThatJson("{\"distinct_id\":\"id-1\",\"event\":\"$feature_flag_called\"" + + ",\"properties\":{\"$feature_flag\":\"java-feature-flag\",\"$feature_flag_response\":\"true\", \"$feature_flag_errored\":\"false\"}" + +",\"timestamp\":\"" + instantExpected + + "\"}").isEqualTo(new JSONObject(json, "distinct_id", "event", "timestamp", "properties").toString()); + } + + @Test + public void testGetAllFeatureFlagsForUser() { + final FeatureFlagPoller featureFlagPoller = new FeatureFlagPoller.Builder("", "", new TestGetter()).build(); + ph = new PostHog.BuilderWithCustomFeatureFlagPoller(featureFlagPoller) + .build(); + + final FeatureFlagConfig config = new FeatureFlagConfig.Builder("id-1") + .build(); + final Map> flags = ph.getAllFeatureFlags(config); + assertEquals(1, flags.size()); + assertTrue(flags.containsKey("java-feature-flag")); + assertTrue(flags.get("java-feature-flag").isPresent()); + assertEquals("variant-1", flags.get("java-feature-flag").get()); } @Test diff --git a/posthog/src/test/java/com/posthog/java/TestGetter.java b/posthog/src/test/java/com/posthog/java/TestGetter.java new file mode 100644 index 0000000..b51479e --- /dev/null +++ b/posthog/src/test/java/com/posthog/java/TestGetter.java @@ -0,0 +1,100 @@ +package com.posthog.java; + +import org.json.JSONObject; + +import java.util.Map; + +public class TestGetter implements Getter { + + private String jsonString; + + public String getJsonString() { + if (jsonString == null) { + return "{\n" + + " \"flags\": [\n" + + " {\n" + + " \"id\": 1000,\n" + + " \"team_id\": 20000,\n" + + " \"name\": \"\",\n" + + " \"key\": \"java-feature-flag\",\n" + + " \"filters\": {\n" + + " \"groups\": [\n" + + " {\n" + + " \"variant\": \"variant-2\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"id\",\n" + + " \"type\": \"cohort\",\n" + + " \"value\": 17231\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 39\n" + + " },\n" + + " {\n" + + " \"variant\": null,\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": [\n" + + " \"id-1\"\n" + + " ],\n" + + " \"operator\": \"exact\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 100\n" + + " },\n" + + " {\n" + + " \"variant\": \"variant-2\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": \"a-value\",\n" + + " \"operator\": \"icontains\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 41\n" + + " }\n" + + " ],\n" + + " \"payloads\": {\n" + + " \"variant-1\": \"{\\\"something\\\": 1}\",\n" + + " \"variant-2\": \"1\"\n" + + " },\n" + + " \"multivariate\": {\n" + + " \"variants\": [\n" + + " {\n" + + " \"key\": \"variant-1\",\n" + + " \"name\": \"\",\n" + + " \"rollout_percentage\": 100\n" + + " },\n" + + " {\n" + + " \"key\": \"variant-2\",\n" + + " \"name\": \"with description\",\n" + + " \"rollout_percentage\": 0\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"deleted\": false,\n" + + " \"active\": true,\n" + + " \"ensure_experience_continuity\": false\n" + + " }\n" + + " ],\n" + + " \"group_type_mapping\": {},\n" + + " \"cohorts\": {}\n" + + "}"; + } + return jsonString; + } + + public void setJsonString(String jsonString) { + this.jsonString = jsonString; + } + + @Override + public JSONObject get(String route, Map headers) { + return new JSONObject(this.getJsonString()); + } + +} diff --git a/posthog/src/test/java/com/posthog/java/flags/FeatureFlagParserTest.java b/posthog/src/test/java/com/posthog/java/flags/FeatureFlagParserTest.java new file mode 100644 index 0000000..e40c287 --- /dev/null +++ b/posthog/src/test/java/com/posthog/java/flags/FeatureFlagParserTest.java @@ -0,0 +1,180 @@ +package com.posthog.java.flags; + +import org.json.JSONObject; +import org.junit.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class FeatureFlagParserTest { + + @Test + public void testParse() { + // Arrange + String jsonString = "{\n" + + " \"flags\": [\n" + + " {\n" + + " \"id\": 1000,\n" + + " \"team_id\": 20000,\n" + + " \"name\": \"\",\n" + + " \"key\": \"java-feature-flag\",\n" + + " \"filters\": {\n" + + " \"groups\": [\n" + + " {\n" + + " \"variant\": \"variant-1\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": \"is_set\",\n" + + " \"operator\": \"is_set\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 53\n" + + " },\n" + + " {\n" + + " \"variant\": \"variant-2\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"id\",\n" + + " \"type\": \"cohort\",\n" + + " \"value\": 17231\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 39\n" + + " },\n" + + " {\n" + + " \"variant\": null,\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": [\n" + + " \"\\\"id-1\\\"\"\n" + + " ],\n" + + " \"operator\": \"exact\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 30\n" + + " },\n" + + " {\n" + + " \"variant\": \"variant-2\",\n" + + " \"properties\": [\n" + + " {\n" + + " \"key\": \"distinct_id\",\n" + + " \"type\": \"person\",\n" + + " \"value\": \"a-value\",\n" + + " \"operator\": \"icontains\"\n" + + " }\n" + + " ],\n" + + " \"rollout_percentage\": 41\n" + + " }\n" + + " ],\n" + + " \"payloads\": {\n" + + " \"variant-1\": \"{\\\"something\\\": 1}\",\n" + + " \"variant-2\": \"1\"\n" + + " },\n" + + " \"multivariate\": {\n" + + " \"variants\": [\n" + + " {\n" + + " \"key\": \"variant-1\",\n" + + " \"name\": \"\",\n" + + " \"rollout_percentage\": 100\n" + + " },\n" + + " {\n" + + " \"key\": \"variant-2\",\n" + + " \"name\": \"with description\",\n" + + " \"rollout_percentage\": 0\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"deleted\": false,\n" + + " \"active\": true,\n" + + " \"ensure_experience_continuity\": false\n" + + " }\n" + + " ],\n" + + " \"group_type_mapping\": {},\n" + + " \"cohorts\": {}\n" + + "}"; + + // Act + FeatureFlags flags = FeatureFlagParser.parse(new JSONObject(jsonString)); + + // Assert + assertNotNull(flags); + assertEquals(1, flags.getFlags().size()); + FeatureFlag flag = flags.getFlags().get(0); + assertEquals(1000, flag.getId()); + assertEquals(20000, flag.getTeamId()); + assertEquals("java-feature-flag", flag.getKey()); + assertTrue(flag.isActive()); + assertFalse(flag.isSimpleFlag()); + assertFalse(flag.isEnsureExperienceContinuity()); + assertFalse(flag.isSimpleFlag()); + assertFalse(flag.isDeleted()); + + assertTrue(flag.getFilter().isPresent()); + final FeatureFlagFilter filter = flag.getFilter().get(); + assertEquals(0, filter.getAggregationGroupTypeIndex()); + assertEquals(4, filter.getGroups().size()); + assertEquals(2, filter.getPayloads().size()); + + final List groups = filter.getGroups(); + assertEquals(4, groups.size()); + assertTrue(groups.get(0).getVariant().isPresent()); + assertEquals("variant-1", groups.get(0).getVariant().get()); + assertEquals(1, groups.get(0).getProperties().size()); + assertEquals(53, groups.get(0).getRolloutPercentage()); + assertFalse(groups.get(0).getProperties().get(0).getValue().isEmpty()); + assertTrue(groups.get(0).getProperties().get(0).getType().isPresent()); + assertEquals("person", groups.get(0).getProperties().get(0).getType().get()); + + assertTrue(groups.get(1).getVariant().isPresent()); + assertEquals("variant-2", groups.get(1).getVariant().get()); + assertEquals(1, groups.get(1).getProperties().size()); + assertEquals(39, groups.get(1).getRolloutPercentage()); + assertFalse(groups.get(1).getProperties().get(0).getValue().isEmpty()); + assertTrue(groups.get(1).getProperties().get(0).getType().isPresent()); + assertEquals("cohort", groups.get(1).getProperties().get(0).getType().get()); + + assertTrue(filter.getMultivariate().isPresent()); + final FeatureFlagVariants multivariate = filter.getMultivariate().get(); + assertEquals(2, multivariate.getVariants().size()); + + final FeatureFlagVariant variant1 = multivariate.getVariants().get(0); + assertEquals("variant-1", variant1.getKey()); + assertEquals("", variant1.getName()); + assertEquals(100, variant1.getRolloutPercentage()); + + final FeatureFlagVariant variant2 = multivariate.getVariants().get(1); + assertEquals("variant-2", variant2.getKey()); + assertEquals("with description", variant2.getName()); + assertEquals(0, variant2.getRolloutPercentage()); + + final FeatureFlagCondition group = filter.getGroups().get(0); + assertTrue(group.getVariant().isPresent()); + assertEquals("variant-1", group.getVariant().get()); + assertEquals(1, group.getProperties().size()); + assertEquals("distinct_id", group.getProperties().get(0).getKey()); + + final FeatureFlagProperty property = group.getProperties().get(0); + assertTrue(property.getOperator().isPresent()); + assertEquals(FeatureFlagPropertyOperator.IS_SET, property.getOperator().get()); + assertFalse(property.getValue().isEmpty()); + assertEquals(Collections.singletonList("is_set"), property.getValue()); + assertTrue(property.getType().isPresent()); + assertEquals("person", property.getType().get()); + assertEquals("distinct_id", property.getKey()); + + assertEquals(2, filter.getPayloads().size()); + assertTrue(filter.getPayloads().containsKey("variant-1")); + assertEquals("{\"something\": 1}", filter.getPayloads().get("variant-1")); + + assertTrue(filter.getPayloads().containsKey("variant-2")); + assertEquals("1", filter.getPayloads().get("variant-2")); + } + +}