diff --git a/README.md b/README.md index 27096d3..24db4b4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,45 @@ -# ffc-java-sdk +# JAVA Server Side SDK + +## Introduction + +This is the Java Server Side SDK for the feature management platform [feature-flags.co](feature-flags.co). It is +intended for use in a multiple-users Java server applications. + +This SDK has two main purposes: + +- Store the available feature flags and evaluate the feature flags by given user in the server side SDK +- Sends feature flags usage, and custom events for the insights and A/B/n testing. + +## Data synchonization + +We use websocket to make the local data synchronized with the server, and then store them in the memory by default. +Whenever there is any changes to a feature flag or his related data, the changes would be pushed to the SDK, the average +synchronization time is less than **100** ms. Be aware the websocket connection can be interrupted by any error or +internet interruption, but it would be restored automatically right after the problem is gone. + +## Offline mode support + +In the offline mode, SDK DOES not exchange any data with [feature-flags.co](feature-flags.co) + +In the following situation, the SDK would work when there is no internet connection: it has been initialized in +using `co.featureflags.server.exterior.FFCClient#initializeFromExternalJson(json)` + +To open the offline mode: + +``` + FFCConfig config = new FFCConfig.Builder() + .offline(false) + .build() + FFCClient client = new FFCClientImp(envSecret, config); +``` + +## Evaluation of a feature flag + +SDK will initialize all the related data(feature flags, segments etc.) in the bootstrapping and receive the data updates +in real time, as mentioned in the above + +After initialization, the SDK has all the feature flags in the memory and all evaluation is done locally and +synchronously, the average evaluation time is < **10** ms. ## Installation @@ -21,3 +62,162 @@ install the sdk in using maven ``` + +## SDK + +### FFCClient + +Applications SHOULD instantiate a single instance for the lifetime of the application. In the case where an application +needs to evaluate feature flags from different environments, you may create multiple clients, but they should still be +retained for the lifetime of the application rather than created per request or per thread. + +### Bootstrapping + +The bootstrapping is in fact the call of constructor of `FFCClientImp`, in which the SDK will be initialized, using +streaming from [feature-flags.co](feature-flags.co). + +The constructor will return when it successfully connects, or when the timeout set +by `FFCConfig.Builder#startWaitTime(Duration)` +(default: 15 seconds) expires, whichever comes first. If it has not succeeded in connecting when the timeout elapses, +you will receive the client in an uninitialized state where feature flags will return default values; it will still +continue trying to connect in the background unless there has been an `java.net.ProtocolException` or you close the +client(using `close()`). You can detect whether initialization has succeeded by calling `isInitialized()`. + +``` +FFCClient client = new FFCClient(sdkKey, config); +if(client.isInitialized()){ +// do whatever is appropriate +} +``` + +If you prefer to have the constructor return immediately, and then wait for initialization to finish at some other +point, you can use `getDataUpdateStatusProvider()`, which provides an asynchronous way, as follows: + +``` +FFCConfig config = new FFCConfig.Builder() + .startWait(Duration.ZERO) + .build(); +FFCClient client = new FFCClient(sdkKey, config); + +// later, when you want to wait for initialization to finish: +boolean inited = client.getDataUpdateStatusProvider().waitForOKState(Duration.ofSeconds(15)) +if (!inited) { + // do whatever is appropriate if initialization has timed out +} +``` + +Note that the _**sdkKey(envSecret)**_ is mandatory. + +### FFCConfig and Components + +`FFCConfig` exposes advanced configuration options for the `FFCClient`. + +`startWaitTime`: how long the constructor will block awaiting a successful data sync. Setting this to a zero or negative +duration will not block and cause the constructor to return immediately. + +`offline`: Set whether SDK is offline. when set to true no connection to feature-flag.co anymore + +We strongly recommend to use the default configuration or just set `startWaitTime` or `offline` if necessary. + + + +``` +// default configuration +FFCConfig config = FFCConfig.DEFAULT + +// set startWaitTime and offline +FFCConfig config = new FFCConfig.Builder() + .startWaitTime(Duration.ZERO) + .offline(false) + .build() +FFCClient client = new FFCClient(sdkKey, config); + +// default configuration +FFCClient client = new FFCClient(sdkKey); +``` + +`FFCConfig` provides advanced configuration options for setting the SDK component or you want to customize the behavior +of build-in components. + +`HttpConfigFactory`: Interface for a factory that creates an `HttpConfig`. SDK sets the SDK's networking configuration, +using a factory object. This object by defaut is a configuration builder obtained from `Factory#httpConfigFactory()`. +With `HttpConfig`, Sets connection/read/write timeout, proxy or insecure/secure socket. + +``` + +HttpConfigFactory factory = Factory.httpConfigFactory() + .connectTime(Duration.ofMillis(3000)) + .httpProxy("my-proxy", 9000) + +FFCConfig config = new FFCConfig.Builder() + .httpConfigFactory(factory) + .build(); +``` + + +`DataStorageFactory` Interface for a factory that creates some implementation of `DataStorage`, that holds feature flags, +user segments or any other related data received by the SDK. SDK sets the implementation of the data storage, using `Factory#inMemoryDataStorageFactory()` +to instantiate a memory data storage. Developers can customize the data storage to persist received data in redis, mongodb, etc. + +``` +FFCConfig config = new FFCConfig.Builder() + .dataStorageFactory(factory) + .build(); + +``` + +`UpdateProcessorFactory` SDK sets the implementation of the `UpdateProcessor` that receives feature flag data from feature-flag.co, +using a factory object. The default is `Factory#streamingBuilder()`, which will create a streaming, using websocket. +If Developers would like to know what the implementation is, they can read the javadoc and source code. + +`InsightProcessorFactory` SDK sets the implementation of `InsightProcessor` to be used for processing analytics events, +using a factory object. The default is `Factory#insightProcessorFactory()`. If Developers would like to know what the implementation is, +they can read the javadoc and source code. + +###Evaluation + +SDK calculates the value of a feature flag for a given user, and returns a flag vlaue/an object that describes the way +that the value was determined. + +`FFUser`: A collection of attributes that can affect flag evaluation, usually corresponding to a user of your application. +This object contains built-in properties(`key`, `userName`, `email` and `country`). The only mandatory property is the key, +which must uniquely identify each user; this could be a username or email address for authenticated users, or a ID for anonymous users. +All other built-in properties are optional, it's strongly recommended to set userName in order to search your user quickly +You may also define custom properties with arbitrary names and values. + +``` + FFCClient client = new FFCClientImp(envSecret); + + // FFUser creation + FFCClient user = new FFCClient.Builder("key") + .userName("name") + .country("country") + .email("email@xxx.com") + .custom("property", "value") + .build() + + // Evaluation details + FlagState res = client.variationDetail("flag key", user, "Not Found"); + // Flag value + String res = client.variation("flag key", user, "Not Found"); + +``` + +If evaluation called before Java SDK client initialized or you set the wrong flag key or user for the evaluation, SDK will return +the default value you set. The `FlagState` will explain the reason of the last evaluation error. + +SDK support the String, Boolean, and Number as the return type of flag values, see JavaDocs for more details. + +### Experiments (A/B/n Testing) +We support automatic experiments for pageviews and clicks, you just need to set your experiment on our SaaS platform, then you should be able to see the result in near real time after the experiment is started. + +In case you need more control over the experiment data sent to our server, we offer a method to send custom event. +``` +client.trackMetric(user, eventName, numericValue); +``` +**numericValue** is not mandatory, the default value is **1**. + +Make sure `trackMetric` is called after the related feature flag is called by simply calling `variation` or `variationDetail` +otherwise, the custom event won't be included into the experiment result. + + diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar index 6252cd9..cd5d096 100644 Binary files a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar and b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar differ diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar.md5 b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar.md5 index dc06c64..521b32e 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar.md5 +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar.md5 @@ -1 +1 @@ -31924decb4745685509f3a6a17da81f3 \ No newline at end of file +596e9cb4c5bf3447a78efb7117d3f07c \ No newline at end of file diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar.sha1 b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar.sha1 index 33b4783..c990cd1 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar.sha1 +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-javadoc.jar.sha1 @@ -1 +1 @@ -a3e448f5686e77497649232bcd466add76fbb66e \ No newline at end of file +1f565d07296a4829817b20833785c8142f0b0e6f \ No newline at end of file diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar index a40c81e..930c523 100644 Binary files a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar and b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar differ diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar.md5 b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar.md5 index e0ad6b8..2441ace 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar.md5 +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar.md5 @@ -1 +1 @@ -6f920804887a2a7165f1aef07f8bb4e7 \ No newline at end of file +0c86ee186b970f9eb309b8fe40582307 \ No newline at end of file diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar.sha1 b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar.sha1 index 8d68b8c..a181b47 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar.sha1 +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0-sources.jar.sha1 @@ -1 +1 @@ -e364cf172317e64b0f95675dfbf87588dd86bf64 \ No newline at end of file +a9ba552804bfd684142874f877facef3df233480 \ No newline at end of file diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar index c56843a..bb9cbf1 100644 Binary files a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar and b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar differ diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar.md5 b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar.md5 index be25eae..8115525 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar.md5 +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar.md5 @@ -1 +1 @@ -a1d97088259e78babbf333b9c14fc3ed \ No newline at end of file +a11b951211a7f81926f828957f43cf8b \ No newline at end of file diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar.sha1 b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar.sha1 index 44e2536..eabe030 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar.sha1 +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/1.0/ffc-java-server-sdk-1.0.jar.sha1 @@ -1 +1 @@ -819c8b1b2232697814ced0daf24c7ebb040a635b \ No newline at end of file +df013663be8cec42f9124ae354a2ca3b7a083c88 \ No newline at end of file diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml b/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml index 8147fa0..17960c0 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml @@ -7,6 +7,6 @@ 1.0 - 20220224112944 + 20220302204354 diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml.md5 b/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml.md5 index d5215fc..4abccac 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml.md5 +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml.md5 @@ -1 +1 @@ -5462b4675b9a3abe371c3f741a5188db \ No newline at end of file +f040fe1eb0dbfb515853d7354c5e2ccc \ No newline at end of file diff --git a/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml.sha1 b/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml.sha1 index bdfa7de..b98b08f 100644 --- a/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml.sha1 +++ b/maven-repo/co/featureflags/ffc-java-server-sdk/maven-metadata.xml.sha1 @@ -1 +1 @@ -bd0bae12efd50fdd446c7acfa3e6dbd7165b4b0d \ No newline at end of file +9b4817726df80e8f66f0b987f3a99212a70fcacf \ No newline at end of file diff --git a/src/main/java/co/featureflags/server/ContextImp.java b/src/main/java/co/featureflags/server/ContextImp.java index ad35332..0e2318c 100644 --- a/src/main/java/co/featureflags/server/ContextImp.java +++ b/src/main/java/co/featureflags/server/ContextImp.java @@ -4,11 +4,11 @@ import co.featureflags.server.exterior.Context; import co.featureflags.server.exterior.HttpConfig; -public final class ContextImp implements Context { +final class ContextImp implements Context { private final HttpConfig httpConfig; private final BasicConfig basicConfig; - public ContextImp(String envSecret, FFCConfig config) { + ContextImp(String envSecret, FFCConfig config) { this.basicConfig = new BasicConfig(envSecret, config.isOffline()); this.httpConfig = config.getHttpConfigFactory().createHttpConfig(basicConfig); } diff --git a/src/main/java/co/featureflags/server/DataModel.java b/src/main/java/co/featureflags/server/DataModel.java index 28d0255..255347d 100644 --- a/src/main/java/co/featureflags/server/DataModel.java +++ b/src/main/java/co/featureflags/server/DataModel.java @@ -17,20 +17,46 @@ public abstract class DataModel { private DataModel() { } + /** + * interface for the object to represent a versioned/timestamped data + */ public interface TimestampData { Integer FFC_FEATURE_FLAG = 100; Integer FFC_ARCHIVED_VDATA = 200; Integer FFC_PERSISTENT_VDATA = 300; + /** + * return the unique id + * + * @return a string + */ String getId(); + /** + * return true if object is archived + * + * @return true if object is archived + */ boolean isArchived(); + /** + * return the version/timestamp of the object + * + * @return a long value + */ Long getTimestamp(); + /** + * return the type of versioned/timestamped object + * + * @return an integer + */ Integer getType(); } + /** + * the object is an implementation of{@link TimestampData}, to represent the archived data + */ public final static class ArchivedTimestampData implements TimestampData { private final String id; private final Long timestamp; diff --git a/src/main/java/co/featureflags/server/FFCClientImp.java b/src/main/java/co/featureflags/server/FFCClientImp.java index 029175d..61bc953 100644 --- a/src/main/java/co/featureflags/server/FFCClientImp.java +++ b/src/main/java/co/featureflags/server/FFCClientImp.java @@ -310,7 +310,7 @@ public Status.DataUpdateStatusProvider getDataUpdateStatusProvider() { @Override public boolean initializeFromExternalJson(String json) { - if (offline) { + if (offline && StringUtils.isNotBlank(json)) { DataModel.All all = JsonHelper.deserialize(json, DataModel.All.class); if (all.isProcessData()) { DataModel.Data allData = all.data(); @@ -379,4 +379,57 @@ public AllFlagStates getAllLatestFlagsVariations(FFCUser user) { } return AllFlagStates.of(success, errorString, builder.build()); } + + @Override + public void flush() { + this.insightProcessor.flush(); + } + + @Override + public void trackMetric(FFCUser user, String eventName) { + trackMetric(user, eventName, 1.0); + } + + @Override + public void trackMetric(FFCUser user, String eventName, double metricValue) { + if (user == null || StringUtils.isBlank(eventName) || metricValue <= 0) { + Loggers.CLIENT.warn("event/user/metric invalid"); + return; + } + InsightTypes.Event event = InsightTypes.MetricEvent.of(user) + .add(InsightTypes.Metric.of(eventName, metricValue)); + insightProcessor.send(event); + } + + @Override + public void trackMetrics(FFCUser user, String... eventNames) { + if (user == null || eventNames == null || eventNames.length == 0) { + Loggers.CLIENT.warn("user/events invalid"); + return; + } + InsightTypes.Event event = InsightTypes.MetricEvent.of(user); + for (String eventName : eventNames) { + if (StringUtils.isNotBlank(eventName)) { + event.add(InsightTypes.Metric.of(eventName, 1.0)); + } + } + insightProcessor.send(event); + } + + @Override + public void trackMetrics(FFCUser user, Map metrics) { + if (user == null || metrics == null || metrics.isEmpty()) { + Loggers.CLIENT.warn("user/metrics invalid"); + return; + } + InsightTypes.Event event = InsightTypes.MetricEvent.of(user); + for (Map.Entry entry : metrics.entrySet()) { + String eventName = entry.getKey(); + Double metricValue = entry.getValue(); + if (StringUtils.isNotBlank(eventName) && metricValue != null && metricValue > 0D) { + event.add(InsightTypes.Metric.of(eventName, metricValue)); + } + } + insightProcessor.send(event); + } } diff --git a/src/main/java/co/featureflags/server/FFCConfig.java b/src/main/java/co/featureflags/server/FFCConfig.java index 7a03e1b..1534b7e 100644 --- a/src/main/java/co/featureflags/server/FFCConfig.java +++ b/src/main/java/co/featureflags/server/FFCConfig.java @@ -7,6 +7,10 @@ import java.time.Duration; +/** + * This class exposes advanced configuration options for the {@link co.featureflags.server.exterior.FFCClient} + * Instances of this class must be constructed with a {@link co.featureflags.server.FFCConfig.Builder}. + */ public class FFCConfig { static final String DEFAULT_BASE_URI = "https://api.feature-flags.co"; static final String DEFAULT_STREAMING_URI = "wss://api.feature-flags.co"; @@ -69,6 +73,15 @@ public FFCConfig(Builder builder) { builder.httpConfigFactory == null ? Factory.httpConfigFactory() : builder.httpConfigFactory; } + /** + * Builder to create advanced configuration options, calls can be chained. + *

+     *  FFCConfig config = new FFCConfig.Builder()
+     *                     .startWaitTime(Duration.ZERO)
+     *                     .offline(false)
+     *                     .build()
+     * 
+ */ public static class Builder { private DataStorageFactory dataStorageFactory; @@ -82,36 +95,87 @@ public Builder() { super(); } + /** + * Sets the implementation of the data storage to be used for holding feature flags and + * related data received from LaunchDarkly, using a factory object. + * The default is{@link Factory#inMemoryDataStorageFactory()} + * + * @param dataStorageFactory a {@link DataStorageFactory} instance + * @return the builder + */ public Builder dataStorageFactory(DataStorageFactory dataStorageFactory) { this.dataStorageFactory = dataStorageFactory; return this; } + /** + * Sets the implementation of the {@link co.featureflags.server.exterior.UpdateProcessor} that receives feature flag data + * from feature-flag.co, using a factory object. Depending on the implementation, the factory may be a builder that + * allows you to set other configuration options as well. + * The default is{@link Factory#streamingBuilder()} + * + * @param updateProcessorFactory an {@link UpdateProcessorFactory} instance + * @return the builder + */ public Builder updateProcessorFactory(UpdateProcessorFactory updateProcessorFactory) { this.updateProcessorFactory = updateProcessorFactory; return this; } + /** + * Sets the SDK's networking configuration, using a factory object. Depending on the implementation, + * the factory may be a builder that allows you to set other configuration options as well. + * This object by defaut is a configuration builder obtained from {@link Factory#httpConfigFactory()}, + * + * @param httpConfigFactory a {@link HttpConfigFactory} + * @return the builder + */ public Builder httpConfigFactory(HttpConfigFactory httpConfigFactory) { this.httpConfigFactory = httpConfigFactory; return this; } + /** + * Sets the implementation of {@link co.featureflags.server.exterior.InsightProcessor} to be used for processing analytics events, + * using a factory object. Depending on the implementation, the factory may be a builder that allows you to set other configuration options as well. + * The default is{@link Factory#insightProcessorFactory()} + * + * @param insightProcessorFactory an {@link InsightProcessorFactory} + * @return the builder + */ public Builder insightProcessorFactory(InsightProcessorFactory insightProcessorFactory) { this.insightProcessorFactory = insightProcessorFactory; return this; } + /** + * Set whether SDK is offline. + * + * @param offline when set to true no connection to feature-flag.co any more + * @return the builder + */ public Builder offline(boolean offline) { this.offline = offline; return this; } + /** + * Set how long the constructor will block awaiting a successful data sync. + * Setting this to a zero or negative duration will not block and cause the constructor to return immediately. + * + * @param startWaitTime maximum time to wait; null to use the default + * @return the builder + */ public Builder startWaitTime(Duration startWaitTime) { this.startWaitTime = startWaitTime; return this; } + /** + * Builds the configured {@link FFCConfig} + * + * @return a {@link FFCConfig} instance + */ public FFCConfig build() { return new FFCConfig(this); } diff --git a/src/main/java/co/featureflags/server/InsightProcessorBuilder.java b/src/main/java/co/featureflags/server/InsightProcessorBuilder.java index 1f74332..cb40c94 100644 --- a/src/main/java/co/featureflags/server/InsightProcessorBuilder.java +++ b/src/main/java/co/featureflags/server/InsightProcessorBuilder.java @@ -5,6 +5,28 @@ import java.time.Duration; +/** + * Factory to create {@link co.featureflags.server.exterior.InsightProcessor} + *

+ * The SDK normally buffers analytics events and sends them to feature-flag.co at intervals. If you want + * to customize this behavior, create a builder with {@link Factory#insightProcessorFactory()}, change its + * properties with the methods of this class, and pass it to {@link FFCConfig.Builder#insightProcessorFactory(InsightProcessorFactory)}: + *


+ *      InsightProcessorBuilder insightProcessorBuilder = Factory.insightProcessorFactory()
+ *                     .capacity(10000)
+ *
+ *
+ *             FFCConfig config = new FFCConfig.Builder()
+ *                     .insightProcessorFactory(insightProcessorBuilder)
+ *                     .build();
+ *
+ *             FFCClient client = new FFCClientImp(envSecret, config);
+ * 
+ *

+ * Note that this class is in fact only internal use, it's not recommended to customize any behavior in this configuration. + * We just keep the same design pattern in the SDK + */ + public abstract class InsightProcessorBuilder implements InsightEventSenderFactory, InsightProcessorFactory { protected final static String DEFAULT_EVENT_URI = "https://api.feature-flags.co"; diff --git a/src/main/java/co/featureflags/server/InsightTypes.java b/src/main/java/co/featureflags/server/InsightTypes.java index 70d2120..ad97849 100644 --- a/src/main/java/co/featureflags/server/InsightTypes.java +++ b/src/main/java/co/featureflags/server/InsightTypes.java @@ -53,7 +53,7 @@ static FlagEvent of(FFCUser user) { @Override public Event add(Object element) { FlagEventVariation variation = (FlagEventVariation) element; - if (!variation.getVariation().getIndex().equals(NO_EVAL_RES)) { + if (variation != null && !variation.getVariation().getIndex().equals(NO_EVAL_RES)) { userVariations.add(variation); } return this; @@ -65,6 +65,32 @@ public boolean isSendEvent() { } } + @JsonAdapter(MetricEventSerializer.class) + final static class MetricEvent extends Event { + private final List metrics = new ArrayList<>(); + + MetricEvent(FFCUser user) { + super(user); + } + + static MetricEvent of(FFCUser user) { + return new MetricEvent(user); + } + + @Override + public boolean isSendEvent() { + return user != null && !metrics.isEmpty(); + } + + @Override + public Event add(Object element) { + Metric metric = (Metric) element; + if (metric != null) { + metrics.add(metric); + } + return this; + } + } static final class FlagEventVariation { private final String featureFlagKeyName; @@ -94,23 +120,49 @@ public Evaluator.EvalResult getVariation() { } } + static final class Metric { + private final String route = "index/metric"; + private final String type = "CustomEvent"; + private final String eventName; + private final Double numericValue; + private final String appType = "javaserverside"; + + Metric(String eventName, Double numericValue) { + this.eventName = eventName; + this.numericValue = numericValue; + } + + static Metric of(String eventName, Double numericValue) { + return new Metric(eventName, numericValue == null ? 1.0D : numericValue); + } + + public String getEventName() { + return eventName; + } + + public Double getNumericValue() { + return numericValue; + } + + public String getRoute() { + return route; + } + + public String getType() { + return type; + } + + public String getAppType() { + return appType; + } + } + final static class FlagEventSerializer implements JsonSerializer { + @Override public JsonElement serialize(FlagEvent flagEvent, Type type, JsonSerializationContext jsonSerializationContext) { FFCUser user = flagEvent.getUser(); - JsonObject json = new JsonObject(); - json.addProperty("userName", user.getUserName()); - json.addProperty("email", user.getEmail()); - json.addProperty("country", user.getCountry()); - json.addProperty("userKeyId", user.getKey()); - JsonArray array = new JsonArray(); - for (Map.Entry keyItem : user.getCustom().entrySet()) { - JsonObject p = new JsonObject(); - p.addProperty("name", keyItem.getKey()); - p.addProperty("value", keyItem.getValue()); - array.add(p); - } - json.add("customizedProperties", array); + JsonObject json = serializeUser(user); JsonArray array1 = new JsonArray(); for (FlagEventVariation variation : flagEvent.userVariations) { JsonObject var = new JsonObject(); @@ -129,8 +181,47 @@ public JsonElement serialize(FlagEvent flagEvent, Type type, JsonSerializationCo } } + final static class MetricEventSerializer implements JsonSerializer { + @Override + public JsonElement serialize(MetricEvent metricEvent, Type type, JsonSerializationContext jsonSerializationContext) { + FFCUser user = metricEvent.getUser(); + JsonObject json = serializeUser(user); + JsonArray array1 = new JsonArray(); + for (Metric metric : metricEvent.metrics) { + JsonObject var = new JsonObject(); + var.addProperty("route", metric.getRoute()); + var.addProperty("type", metric.getType()); + var.addProperty("eventName", metric.getEventName()); + var.addProperty("numericValue", metric.getNumericValue()); + var.addProperty("appType", metric.getAppType()); + array1.add(var); + } + json.add("metrics", array1); + return json; + } + } + + private static JsonObject serializeUser(FFCUser user) { + JsonObject json = new JsonObject(); + JsonObject json1 = new JsonObject(); + json1.addProperty("userName", user.getUserName()); + json1.addProperty("email", user.getEmail()); + json1.addProperty("country", user.getCountry()); + json1.addProperty("keyId", user.getKey()); + JsonArray array = new JsonArray(); + for (Map.Entry keyItem : user.getCustom().entrySet()) { + JsonObject p = new JsonObject(); + p.addProperty("name", keyItem.getKey()); + p.addProperty("value", keyItem.getValue()); + array.add(p); + } + json1.add("customizedProperties", array); + json.add("user", json1); + return json; + } + enum InsightMessageType { - EVENT, FLUSH, SHUTDOWN + FLAGS, FLUSH, SHUTDOWN, METRICS, } static final class InsightMessage { @@ -178,7 +269,7 @@ public Event getEvent() { static final class InsightConfig { - private static final String EVENT_PATH = "/api/public/analytics/track/feature-flags"; + private static final String EVENT_PATH = "/api/public/track"; final InsightEventSender sender; final String eventUrl; diff --git a/src/main/java/co/featureflags/server/Insights.java b/src/main/java/co/featureflags/server/Insights.java index 808fece..76af6e0 100644 --- a/src/main/java/co/featureflags/server/Insights.java +++ b/src/main/java/co/featureflags/server/Insights.java @@ -41,7 +41,11 @@ public InsightProcessorImpl(InsightTypes.InsightConfig config) { @Override public void send(InsightTypes.Event event) { if (!closed.get() && event != null) { - putEventAsync(InsightTypes.InsightMessageType.EVENT, event); + if (event instanceof InsightTypes.FlagEvent) { + putEventAsync(InsightTypes.InsightMessageType.FLAGS, event); + } else if (event instanceof InsightTypes.MetricEvent) { + putEventAsync(InsightTypes.InsightMessageType.METRICS, event); + } } } @@ -107,10 +111,7 @@ private final static class FlushPaypladRunner implements Runnable { private final AtomicInteger busyFlushPaypladThreadNum; private final InsightTypes.Event[] payload; - public FlushPaypladRunner(InsightTypes.InsightConfig config, - Semaphore permits, - AtomicInteger busyFlushPaypladThreadNum, - InsightTypes.Event[] payload) { + public FlushPaypladRunner(InsightTypes.InsightConfig config, Semaphore permits, AtomicInteger busyFlushPaypladThreadNum, InsightTypes.Event[] payload) { this.config = config; this.permits = permits; this.busyFlushPaypladThreadNum = busyFlushPaypladThreadNum; @@ -147,19 +148,11 @@ private static final class EventDispatcher { // permits to flush events private final Semaphore permits = new Semaphore(MAX_FLUSH_WORKERS_NUMBER); - public EventDispatcher(InsightTypes.InsightConfig config, - BlockingQueue inbox) { + public EventDispatcher(InsightTypes.InsightConfig config, BlockingQueue inbox) { this.config = config; this.inbox = inbox; - this.threadPoolExecutor = new ThreadPoolExecutor(MAX_FLUSH_WORKERS_NUMBER, - MAX_FLUSH_WORKERS_NUMBER, - 0L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(MAX_QUEUE_SIZE), - Utils.createThreadFactory("flush-payload-worker-%d", true), - new ThreadPoolExecutor.CallerRunsPolicy()); - Thread mainThread = Utils.createThreadFactory("event-dispatcher", true) - .newThread(this::dispatchEvents); + this.threadPoolExecutor = new ThreadPoolExecutor(MAX_FLUSH_WORKERS_NUMBER, MAX_FLUSH_WORKERS_NUMBER, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(MAX_QUEUE_SIZE), Utils.createThreadFactory("flush-payload-worker-%d", true), new ThreadPoolExecutor.CallerRunsPolicy()); + Thread mainThread = Utils.createThreadFactory("event-dispatcher", true).newThread(this::dispatchEvents); mainThread.start(); } @@ -175,7 +168,8 @@ private void dispatchEvents() { for (InsightTypes.InsightMessage message : messages) { try { switch (message.getType()) { - case EVENT: + case FLAGS: + case METRICS: putEventToNextBuffer(message.getEvent()); break; case FLUSH: @@ -214,8 +208,11 @@ private void putEventToNextBuffer(InsightTypes.Event event) { if (closed.get()) { return; } - Loggers.EVENTS.debug("put event to buffer"); - eventsBufferToNextFlush.add(event); + if (event.isSendEvent()) { + Loggers.EVENTS.debug("put event to buffer"); + eventsBufferToNextFlush.add(event); + } + } private void triggerFlush() { diff --git a/src/main/java/co/featureflags/server/Senders.java b/src/main/java/co/featureflags/server/Senders.java index dc6aecb..249b8e9 100644 --- a/src/main/java/co/featureflags/server/Senders.java +++ b/src/main/java/co/featureflags/server/Senders.java @@ -1,6 +1,5 @@ package co.featureflags.server; -import co.featureflags.commons.json.JsonHelper; import co.featureflags.server.exterior.DefaultSender; import co.featureflags.server.exterior.HttpConfig; import co.featureflags.server.exterior.InsightEventSender; diff --git a/src/main/java/co/featureflags/server/Status.java b/src/main/java/co/featureflags/server/Status.java index 03229c5..8f8f5a2 100644 --- a/src/main/java/co/featureflags/server/Status.java +++ b/src/main/java/co/featureflags/server/Status.java @@ -3,7 +3,6 @@ import co.featureflags.server.exterior.DataStorage; import co.featureflags.server.exterior.DataStoreTypes; import com.google.common.base.MoreObjects; -import org.apache.commons.lang3.StringUtils; import java.io.Serializable; import java.time.Duration; @@ -22,9 +21,40 @@ public abstract class Status { public static final String UNKNOWN_ERROR = "Unknown error"; public static final String UNKNOWN_CLOSE_CODE = "Unknown close code"; - + /** + * possible values for {@link co.featureflags.server.exterior.UpdateProcessor} + */ public enum StateType { - INITIALIZING, OK, INTERRUPTED, OFF + /** + * The initial state of the data source when the SDK is being initialized. + *

+ * If it encounters an error that requires it to retry initialization, the state will remain at + * {@link #INITIALIZING} until it either succeeds and becomes {@link #OK}, or permanently fails and + * becomes {@link #OFF}. + */ + INITIALIZING, + /** + * Indicates that the update processing is currently operational and has not had any problems since the + * last time it received data. + *

+ * In streaming mode, this means that there is currently an open stream connection and that at least + * one initial message has been received on the stream. + */ + OK, + /** + * Indicates that the update processing encountered an error that it will attempt to recover from. + *

+ * In streaming mode, this means that the stream connection failed, or had to be dropped due to some + * other error, and will be retried after a backoff delay. + */ + INTERRUPTED, + /** + * Indicates that the update processing has been permanently shut down. + *

+ * This could be because it encountered an unrecoverable error or because the SDK client was + * explicitly shut down. + */ + OFF } public static class ErrorInfo implements Serializable { @@ -144,17 +174,71 @@ public int hashCode() { * so that the SDK can perform any other necessary operations that should perform around data updating. *

* if you overwrite the our default Update Processor,you should integrate{@link DataUpdator} to push data - * and maintain the processor status in your own code + * and maintain the processor status in your own code, but note that the implementation of this interface is not public */ public interface DataUpdator { + /** + * Overwrites the storage with a set of items for each collection, if the new version > the old one + *

+ * If the underlying data store throws an error during this operation, the SDK will catch it, log it, + * and set the data source state to {@link StateType#INTERRUPTED}.It will not rethrow the error to other level + * but will simply return {@code false} to indicate that the operation failed. + * + * @param allData map of {@link co.featureflags.server.exterior.DataStoreTypes.Category} and their data set {@link co.featureflags.server.exterior.DataStoreTypes.Item} + * @param version the version of dataset, Ordinarily it's a timestamp. + * @return true if the update succeeded + */ boolean init(Map> allData, Long version); + /** + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version; for inserts, if the version > the existing one, it will replace + * the existing one. + *

+ * If the underlying data store throws an error during this operation, the SDK will catch it, log it, + * and set the data source state to {@link StateType#INTERRUPTED}.It will not rethrow the error to other level + * but will simply return {@code false} to indicate that the operation failed. + * + * @param category specifies which collection to use + * @param key the unique key of the item in the collection + * @param item the item to insert or update + * @param version the version of item + * @return true if success + */ boolean upsert(DataStoreTypes.Category category, String key, DataStoreTypes.Item item, Long version); + /** + * Informs the SDK of a change in the {@link co.featureflags.server.exterior.UpdateProcessor} status. + *

+ * {@link co.featureflags.server.exterior.UpdateProcessor} implementations should use this method + * if they have any concept of being in a valid state, a temporarily disconnected state, or a permanently stopped state. + *

+ * If {@code newState} is different from the previous state, and/or {@code newError} is non-null, the + * SDK will start returning the new status (adding a timestamp for the change) from + * {@link DataUpdateStatusProvider#getState()}, and will trigger status change events to any + * registered listeners. + *

+ * A special case is that if {@code newState} is {@link StateType#INTERRUPTED}, + * but the previous state was {@link StateType#INITIALIZING}, the state will remain at {@link StateType#INITIALIZING} + * because {@link StateType#INTERRUPTED} is only meaningful after a successful startup. + * + * @param newState the data storage state + * @param message the data source state + */ void updateStatus(StateType newState, ErrorInfo message); + /** + * return the latest version of {@link DataStorage} + * + * @return a long value + */ long getVersion(); + /** + * return true if the {@link DataStorage} is well initialized + * + * @return true if the {@link DataStorage} is well initialized + */ boolean storageInitialized(); } @@ -242,7 +326,8 @@ public boolean storageInitialized() { // blocking util you get the desired state, time out reaches or thread is interrupted boolean waitFor(StateType state, Duration timeout) throws InterruptedException { - Instant deadline = Instant.now().plus(timeout); + Duration timeout1 = timeout == null ? Duration.ZERO : timeout; + Instant deadline = Instant.now().plus(timeout1); synchronized (lockObject) { while (true) { StateType curr = currentState.getStateType(); @@ -252,7 +337,7 @@ boolean waitFor(StateType state, Duration timeout) throws InterruptedException { if (curr == StateType.OFF) { return false; } - if (timeout.isZero() || timeout.isNegative()) { + if (timeout1.isZero() || timeout1.isNegative()) { lockObject.wait(); } else { // block the consumer thread util getting desired state @@ -276,12 +361,66 @@ State getCurrentState() { } + /** + * An interface to query the status of a {@link co.featureflags.server.exterior.UpdateProcessor} + * With the build-in implementation, this might be useful if you want to use SDK without waiting for it to initialize + */ public interface DataUpdateStatusProvider { + /** + * Returns the current status of the {@link co.featureflags.server.exterior.UpdateProcessor} + *

+ * All of the {@link co.featureflags.server.exterior.UpdateProcessor} implementations are guaranteed to update this status + * whenever they successfully initialize, encounter an error, or recover after an error. + *

+ * For a custom implementation, it is the responsibility of the data source to report its status via {@link DataUpdator}; + * if it does not do so, the status will always be reported as + * {@link StateType#INITIALIZING}. + * + * @return the latest status; will never be null + */ State getState(); + /** + * A method for waiting for a desired connection state after bootstrapping + *

+ * If the current state is already {@code desiredState} when this method is called, it immediately returns. + * Otherwise, it blocks until 1. the state has become {@code desiredState}, 2. the state has become + * {@link StateType#OFF} , 3. the specified timeout elapses, or 4. the current thread is deliberately interrupted with {@link Thread#interrupt()}. + *

+ * A scenario in which this might be useful is if you want to use SDK without waiting + * for it to initialize, and then wait for initialization at a later time or on a different point: + *


+         *     FFCConfig config = new FFCConfig.Builder()
+         *         .startWait(Duration.ZERO)
+         *         .build();
+         *     FFCClient client = new FFCClient(sdkKey, config);
+         *
+         *     // later, when you want to wait for initialization to finish:
+         *     boolean inited = client.getDataUpdateStatusProvider().waitFor(StateType.OK, Duration.ofSeconds(15))
+         *     if (!inited) {
+         *         // do whatever is appropriate if initialization has timed out
+         *     }
+         * 
+ * + * @param state the desired connection state (normally this would be {@link StateType#OK}) + * @param timeout the maximum amount of time to wait-- or {@link Duration#ZERO} to block indefinitely + * (unless the thread is explicitly interrupted) + * @return true if the connection is now in the desired state; false if it timed out, or if the state + * changed to 2 and that was not the desired state + * @throws InterruptedException if {@link Thread#interrupt()} was called on this thread while blocked + */ boolean waitFor(StateType state, Duration timeout) throws InterruptedException; + /** + * alias of {@link #waitFor(StateType, Duration)} in {@link StateType#OK} + * + * @param timeout the maximum amount of time to wait-- or {@link Duration#ZERO} to block indefinitely + * (unless the thread is explicitly interrupted) + * @return true if the connection is now in {@link StateType#OK}; false if it timed out, or if the state + * changed to {@link StateType#OFF} and that was not the desired state + * @throws InterruptedException + */ boolean waitForOKState(Duration timeout) throws InterruptedException; } diff --git a/src/main/java/co/featureflags/server/StreamingBuilder.java b/src/main/java/co/featureflags/server/StreamingBuilder.java index 7f38ffe..2dc9417 100644 --- a/src/main/java/co/featureflags/server/StreamingBuilder.java +++ b/src/main/java/co/featureflags/server/StreamingBuilder.java @@ -5,6 +5,20 @@ import java.time.Duration; +/** + * Factory to create a {@link Streaming} implementation + * By default, the SDK uses a streaming connection to receive feature flag data. If you want to customize the behavior of the connection, + * create a builder with {@link Factory#streamingBuilder()}, change its properties with the methods of this class, + * and pass it to {@link FFCConfig.Builder#updateProcessorFactory(UpdateProcessorFactory)}: + *

+ *      StreamingBuilder streamingBuilder = Factory.streamingBuilder()
+ *           .firstRetryDelay(Duration.ofSeconds(1));
+ *       FFCConfig config = new FFCConfig.Builder()
+ *           .updateProcessorFactory(streamingBuilder)
+ *           .build();
+ *       FFCClient client = new FFCClientImp(envSecret, config);
+ * 
+ */ public abstract class StreamingBuilder implements UpdateProcessorFactory { protected static final String DEFAULT_STREAMING_URI = "wss://api.feature-flags.co"; protected static final Duration DEFAULT_FIRST_RETRY_DURATION = Duration.ofSeconds(1); @@ -12,17 +26,39 @@ public abstract class StreamingBuilder implements UpdateProcessorFactory { protected Duration firstRetryDelay; protected Integer maxRetryTimes = 0; + /** + * internal test purpose only + * + * @param uri streaming base uri + * @return the builder + */ public StreamingBuilder newStreamingURI(String uri) { this.streamingURI = StringUtils.isBlank(uri) ? DEFAULT_STREAMING_URI : uri; return this; } + /** + * Sets the initial reconnect delay for the streaming connection. + *

+ * The streaming service uses a backoff algorithm (with jitter) every time the connection needs + * to be reestablished. The delay for the first reconnection will start near this value, and then + * increase exponentially for any subsequent connection failures. + * + * @param duration the reconnect time base value; null to use the default(1s) + * @return the builder + */ public StreamingBuilder firstRetryDelay(Duration duration) { this.firstRetryDelay = (duration == null || duration.minusSeconds(1).isNegative()) ? DEFAULT_FIRST_RETRY_DURATION : duration; return this; } + /** + * Sets the max retry times for the streaming failures. + * + * @param maxRetryTimes an int value if less than or equals to 0, use the default + * @return the builder + */ public StreamingBuilder maxRetryTimes(Integer maxRetryTimes) { this.maxRetryTimes = maxRetryTimes; return this; diff --git a/src/main/java/co/featureflags/server/Utils.java b/src/main/java/co/featureflags/server/Utils.java index 16a1569..810a354 100644 --- a/src/main/java/co/featureflags/server/Utils.java +++ b/src/main/java/co/featureflags/server/Utils.java @@ -76,11 +76,7 @@ public static void buildProxyAndSocketFactoryFor(OkHttpClient.Builder builder, H } if (httpConfig.sslSocketFactory() != null) { - if (httpConfig.trustManager() != null) { - builder.sslSocketFactory(httpConfig.sslSocketFactory(), httpConfig.trustManager()); - } else { - builder.sslSocketFactory(httpConfig.sslSocketFactory()); - } + builder.sslSocketFactory(httpConfig.sslSocketFactory(), httpConfig.trustManager()); } if (httpConfig.proxy() != null) { diff --git a/src/main/java/co/featureflags/server/exterior/BasicConfig.java b/src/main/java/co/featureflags/server/exterior/BasicConfig.java index 3ce7df6..2ae19ee 100644 --- a/src/main/java/co/featureflags/server/exterior/BasicConfig.java +++ b/src/main/java/co/featureflags/server/exterior/BasicConfig.java @@ -1,19 +1,36 @@ package co.featureflags.server.exterior; +/** + * the basic configuration of SDK that will be used for all components + */ public final class BasicConfig { private final String envSecret; private final boolean offline; + /** + * constructs an instance + * + * @param envSecret the env secret of your env + * @param offline true if the SDK was configured to be completely offline + */ public BasicConfig(String envSecret, boolean offline) { this.envSecret = envSecret; this.offline = offline; } + /** + * return the env secret + * @return a string + */ public String getEnvSecret() { return envSecret; } + /** + * Returns true if the client was configured to be completely offline. + * @return true if offline + */ public boolean isOffline() { return offline; } diff --git a/src/main/java/co/featureflags/server/exterior/Context.java b/src/main/java/co/featureflags/server/exterior/Context.java index ab41de8..c38f470 100644 --- a/src/main/java/co/featureflags/server/exterior/Context.java +++ b/src/main/java/co/featureflags/server/exterior/Context.java @@ -1,9 +1,25 @@ package co.featureflags.server.exterior; +/** + * Context is used to create components, context information provided by the {@link co.featureflags.server.FFCClientImp} + * This is passed as parameter to component factories. Component factories do not receive the entire {@link co.featureflags.server.FFCConfig} + * because it contains only factory implementations. + *

+ * Note that the actual implementation class may contain other properties that are only relevant to the built-in + * SDK components and are therefore not part of the public interface; this allows the SDK to add its own + * context information as needed without disturbing the public API. + */ public interface Context { - + /** + * the basic config, ex envSecret, offline, of SDK + * @return basic configuration + */ BasicConfig basicConfig(); + /** + * The networking properties that apply to all components. + * @return http configuration + */ HttpConfig http(); } diff --git a/src/main/java/co/featureflags/server/exterior/DataStorage.java b/src/main/java/co/featureflags/server/exterior/DataStorage.java index e5fad05..a60ee28 100644 --- a/src/main/java/co/featureflags/server/exterior/DataStorage.java +++ b/src/main/java/co/featureflags/server/exterior/DataStorage.java @@ -3,19 +3,76 @@ import java.io.Closeable; import java.util.Map; +/** + * Interface for a data storage that holds feature flags, user segments or any other related data received by the SDK. + *

+ * Ordinarily, the only implementations of this interface are the default in-memory implementation, + * which holds references to actual SDK data model objects. + *

+ * All implementations should permit concurrent access and updates. + */ public interface DataStorage extends Closeable { + /** + * Overwrites the storage with a set of items for each collection, if the new version > the old one + *

+ * + * @param allData map of {@link co.featureflags.server.exterior.DataStoreTypes.Category} and their data set {@link co.featureflags.server.exterior.DataStoreTypes.Item} + * @param version the version of dataset, Ordinarily it's a timestamp. + */ void init(Map> allData, Long version); + /** + * Retrieves an item from the specified collection, if available. + *

+ * If the item has been achieved and the store contains an achieved placeholder, but it should return null + * + * @param category specifies which collection to use + * @param key the unique key of the item in the collection + * @return a versioned item that contains the stored data or null if item is deleted or unknown + */ DataStoreTypes.Item get(DataStoreTypes.Category category, String key); + /** + * Retrieves all items from the specified collection. + *

+ * If the store contains placeholders for deleted items, it should filter them in the results. + * + * @param category specifies which collection to use + * @return a map of ids and their versioned items + */ Map getAll(DataStoreTypes.Category category); + /** + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version; for inserts, if the version > the existing one, it will replace + * the existing one. + *

+ * The SDK may pass an {@link co.featureflags.server.exterior.DataStoreTypes.Item} that contains a archived object, + * In that case, assuming the version is greater than any existing version of that item, the store should retain + * a placeholder rather than simply not storing anything. + * + * @param category specifies which collection to use + * @param key the unique key of the item in the collection + * @param item the item to insert or update + * @param version the version of item + * @return true if success + */ boolean upsert(DataStoreTypes.Category category, String key, DataStoreTypes.Item item, Long version); + /** + * Checks whether this store has been initialized with any data yet. + * + * @return true if the storage contains data + */ boolean isInitialized(); + /** + * return the latest version of storage + * + * @return a long value + */ long getVersion(); } \ No newline at end of file diff --git a/src/main/java/co/featureflags/server/exterior/DataStorageFactory.java b/src/main/java/co/featureflags/server/exterior/DataStorageFactory.java index fd97e57..22daf70 100644 --- a/src/main/java/co/featureflags/server/exterior/DataStorageFactory.java +++ b/src/main/java/co/featureflags/server/exterior/DataStorageFactory.java @@ -1,5 +1,17 @@ package co.featureflags.server.exterior; +/** + * Interface for a factory that creates some implementation of {@link DataStorage}. + * + * @see co.featureflags.server.Factory + */ public interface DataStorageFactory { + + /** + * Creates an implementation. + * + * @param context allows access to the client configuration + * @return a {@link DataStorage} + */ DataStorage createDataStorage(Context context); } diff --git a/src/main/java/co/featureflags/server/exterior/DataStoreTypes.java b/src/main/java/co/featureflags/server/exterior/DataStoreTypes.java index 8af538d..262bd82 100644 --- a/src/main/java/co/featureflags/server/exterior/DataStoreTypes.java +++ b/src/main/java/co/featureflags/server/exterior/DataStoreTypes.java @@ -7,17 +7,44 @@ import java.io.Serializable; import java.util.*; +/** + * Contains information about the internal data model for storage objects + *

+ * The implementation of internal components is not public to application code (although of course developers can easily + * look at the code or the data) so that changes to SDK implementation details will not be breaking changes to the application. + * This class provide a high-level description of storage objects so that custom integration code or test code can + * store or serialize them. + */ public abstract class DataStoreTypes { + /** + * The {@link Category} instance that describes feature flag data. + *

+ * Applications should not need to reference this object directly.It is public so that custom integrations + * and test code can serialize or deserialize data or inject it into a data storage. + */ public final static Category FEATURES = new Category("featureFlags", "/api/public/sdk/latest-feature-flags", - "streaming"); + "/streaming"); + + /** + * An enumeration of all supported {@link Category} types. + *

+ * Applications should not need to reference this object directly. It is public so that custom data storage + * implementations can determine what kinds of model objects may need to be stored. + */ public final List FFC_ALL_CATS = ImmutableList.of(FEATURES); private DataStoreTypes() { } + /** + * Represents a separated namespace of storable data items. + *

+ * The SDK passes instances of this type to the data store to specify whether it is referring to + * a feature flag, a user segment, etc + */ public static final class Category implements Serializable { private final String name; private final String pollingApiUrl; @@ -29,6 +56,12 @@ private Category(String name, String pollingApiUrl, String streamingApiUrl) { this.streamingApiUrl = streamingApiUrl; } + /** + * build a external category + * + * @param name the name of namespace + * @return a Category + */ public static Category of(String name) { return new Category(name, "unknown", "unknown"); } @@ -58,6 +91,12 @@ public int hashCode() { } } + /** + * Object, that contains s versioned item (or placeholder), storable in a {@link DataStorage}. + *

+ * This is used for the default memory data storage that directly store objects in memory. + */ + public static final class Item { private final DataModel.TimestampData item; @@ -65,6 +104,11 @@ public Item(DataModel.TimestampData data) { this.item = data; } + /** + * Returns a version object or an archived object if the object is archived + * + * @return a {@link co.featureflags.server.DataModel.TimestampData} + */ public DataModel.TimestampData item() { return item; } @@ -90,6 +134,13 @@ public String toString() { } } + /** + * Object, that contains s versioned item (or placeholder), storable in a {@link DataStorage}. + *

+ * This is equivalent to {@link Item}, but is used for persistent data storage(like redis, mongodb etc.). The + * SDK will convert each data item to and from its json string form; the persistent data + * store deals only with the json form. + */ public static final class PersistentItem implements DataModel.TimestampData, Serializable { private final String id; private final Long timestamp; @@ -106,6 +157,15 @@ private PersistentItem(String id, this.json = json; } + /** + * build a PersistentItem instance + * + * @param id unique id of PersistentItem + * @param timestamp the version number, Ordinarily it's a timestamped value + * @param isArchived true if it's an archived object + * @param json the json string + * @return a PersistentItem instance + */ public static PersistentItem of(String id, Long timestamp, Boolean isArchived, @@ -113,21 +173,42 @@ public static PersistentItem of(String id, return new PersistentItem(id, timestamp, isArchived, json); } + /** + * return unique id of PersistentItem + * + * @return a string + */ @Override public String getId() { return id; } + /** + * return true if it's an archived object + * + * @return true if it's an archived object + */ @Override public boolean isArchived() { return isArchived; } + /** + * return the version number, Ordinarily it's a timestamped value + * + * @return a long value + */ @Override public Long getTimestamp() { return timestamp; } + /** + * return the type of PersistentItem + * + * @return a int + * @see co.featureflags.server.DataModel.TimestampData + */ @Override public Integer getType() { return FFC_PERSISTENT_VDATA; diff --git a/src/main/java/co/featureflags/server/exterior/DefaultSender.java b/src/main/java/co/featureflags/server/exterior/DefaultSender.java index 885e192..290c5b0 100644 --- a/src/main/java/co/featureflags/server/exterior/DefaultSender.java +++ b/src/main/java/co/featureflags/server/exterior/DefaultSender.java @@ -2,6 +2,16 @@ import java.io.Closeable; +/** + * interface for the http connection to saas API to send or receive the details of feature flags, user segments, events etc. + */ public interface DefaultSender extends Closeable { + /** + * send the json objects to saas API in the post method + * + * @param url the url to send json + * @param jsonBody json string + * @return a response of saas API + */ String postJson(String url, String jsonBody); } diff --git a/src/main/java/co/featureflags/server/exterior/FFCClient.java b/src/main/java/co/featureflags/server/exterior/FFCClient.java index c4b8d9d..a260cff 100644 --- a/src/main/java/co/featureflags/server/exterior/FFCClient.java +++ b/src/main/java/co/featureflags/server/exterior/FFCClient.java @@ -1,13 +1,12 @@ package co.featureflags.server.exterior; import co.featureflags.commons.model.AllFlagStates; -import co.featureflags.commons.model.EvalDetail; import co.featureflags.commons.model.FFCUser; import co.featureflags.commons.model.FlagState; import co.featureflags.server.Status; import java.io.Closeable; -import java.util.List; +import java.util.Map; /** @@ -185,4 +184,42 @@ public interface FFCClient extends Closeable { * @return an {@link FlagState} object */ FlagState longVariationDetail(String featureFlagKey, FFCUser user, Long defaultValue); + + /** + * Flushes all pending events. + */ + void flush(); + + /** + * tracks that a user performed an event and provides a default numeric value for custom metrics + * + * @param user the user that performed the event + * @param eventName the name of the event + */ + void trackMetric(FFCUser user, String eventName); + + /** + * tracks that a user performed an event, and provides an additional numeric value for custom metrics. + * + * @param user the user that performed the event + * @param eventName the name of the event + * @param metricValue a numeric value used by the experimentation feature in numeric custom metrics. + */ + void trackMetric(FFCUser user, String eventName, double metricValue); + + /** + * tracks that a user performed a series of events with default numeric value for custom metrics + * + * @param user the user that performed the event + * @param eventNames event names + */ + void trackMetrics(FFCUser user, String... eventNames); + + /** + * tracks that a user performed a series of events + * + * @param user the user that performed the event + * @param metrics event name and numeric value in K/V + */ + void trackMetrics(FFCUser user, Map metrics); } diff --git a/src/main/java/co/featureflags/server/exterior/HttpConfig.java b/src/main/java/co/featureflags/server/exterior/HttpConfig.java index 8da20bc..a45ac12 100644 --- a/src/main/java/co/featureflags/server/exterior/HttpConfig.java +++ b/src/main/java/co/featureflags/server/exterior/HttpConfig.java @@ -9,8 +9,23 @@ import java.time.Duration; import java.util.Map; +/** + * Interface to encapsulate top-level HTTP configuration that applies to all SDK components. + *

+ * Use {@link HttpConfigurationBuilder} to construct an instance + *

+ * The SDK's built-in components use OkHttp as the HTTP client implementation, but since OkHttp types + * are not surfaced in the public API and custom components might use some other implementation, this + * class only provides the properties that would be used to create an HTTP client. + */ public interface HttpConfig { + /** + * The connection timeout. This is the time allowed for the HTTP client to connect + * to the server. + * + * @return the connection timeout; + */ Duration connectTime(); /** @@ -20,15 +35,47 @@ public interface HttpConfig { */ Duration socketTime(); + /** + * The proxy configuration, if any. + * + * @return a {@link Proxy} instance or null + */ Proxy proxy(); + /** + * The authentication method to use for a proxy, if any. Ignored if {@link #proxy()} is null. + * + * @return an {@link Authenticator} implementation or null + */ Authenticator authenticator(); + /** + * The configured socket factory for insecure connections. + * + * @return a SocketFactory or null + */ SocketFactory socketFactory(); + /** + * The configured socket factory for secure connections. + * + * @return a SSLSocketFactory or null + */ SSLSocketFactory sslSocketFactory(); + /** + * The configured trust manager for secure connections, if custom certificate verification is needed. + * + * @return an X509TrustManager or null + */ X509TrustManager trustManager(); + /** + * Returns the basic headers that should be added to all HTTP requests from SDK components to + * feature-flag.co API, based on the current SDK configuration. + * + * @return a list of HTTP header names and values + */ + Iterable> headers(); } diff --git a/src/main/java/co/featureflags/server/exterior/HttpConfigFactory.java b/src/main/java/co/featureflags/server/exterior/HttpConfigFactory.java index d5e8754..b14bc74 100644 --- a/src/main/java/co/featureflags/server/exterior/HttpConfigFactory.java +++ b/src/main/java/co/featureflags/server/exterior/HttpConfigFactory.java @@ -1,5 +1,18 @@ package co.featureflags.server.exterior; +import co.featureflags.server.Factory; + +/** + * Interface for a factory that creates an {@link HttpConfig}. + * + * @see co.featureflags.server.Factory + */ public interface HttpConfigFactory { + /** + * Creates the http configuration. + * + * @param config provides the basic SDK configuration properties + * @return an {@link HttpConfig} instance + */ HttpConfig createHttpConfig(BasicConfig config); } diff --git a/src/main/java/co/featureflags/server/exterior/HttpConfigurationBuilder.java b/src/main/java/co/featureflags/server/exterior/HttpConfigurationBuilder.java index a0f8afb..d385c2f 100644 --- a/src/main/java/co/featureflags/server/exterior/HttpConfigurationBuilder.java +++ b/src/main/java/co/featureflags/server/exterior/HttpConfigurationBuilder.java @@ -9,6 +9,23 @@ import java.net.Proxy; import java.time.Duration; + +/** + * Contains methods for configuring the SDK's networking behavior. + *

+ * If you want to set non-default values, create a builder with {@link co.featureflags.server.Factory#httpConfigFactory()}, + * change its properties with the methods of this class and pass it to {@link co.featureflags.server.FFCConfig.Builder#httpConfigFactory(HttpConfigFactory)}: + *


+ *      FFCConfig config = new FFCConfig.Builder()
+ *                     .httpConfigFactory(Factory.httpConfigFactory()
+ *                             .connectTime(Duration.ofMillis(3000))
+ *                             .httpProxy("my-proxy", 9000))
+ *                     .build();
+ * 
+ *

+ * + * @see co.featureflags.server.Factory + */ public abstract class HttpConfigurationBuilder implements HttpConfigFactory { protected final Duration DEFAULT_CONN_TIME = Duration.ofSeconds(10); protected final Duration DEFAULT_SOCK_TIME = Duration.ofSeconds(15); @@ -20,47 +37,96 @@ public abstract class HttpConfigurationBuilder implements HttpConfigFactory { protected SSLSocketFactory sslSocketFactory; protected X509TrustManager x509TrustManager; + /** + * Sets the connection timeout. This is the time allowed for the SDK to make a socket connection to + * any of API. The default value is 10s + * + * @param duration the connection timeout; null to use the default + * @return the builder + */ public HttpConfigurationBuilder connectTime(Duration duration) { this.connectTime = (duration == null || duration.minusSeconds(1).isNegative()) ? DEFAULT_CONN_TIME : duration; return this; } + /** + * Sets the read and write timeout. This is the time allowed for the SDK to read/write + * any of API. The default value is 15s + * + * @param duration the read/write timeout; null to use the default + * @return the builder + */ public HttpConfigurationBuilder socketTime(Duration duration) { this.socketTime = (duration == null || duration.minusSeconds(1).isNegative()) ? DEFAULT_SOCK_TIME : duration; return this; } + /** + * Sets an HTTP proxy for making connections to feature-flag.co + * + * @param proxyHost the proxy hostname + * @param proxyPort the proxy port + * @return the builder + */ public HttpConfigurationBuilder httpProxy(String proxyHost, int proxyPort) { this.proxy = Utils.buildHTTPProxy(proxyHost, proxyPort); return this; } + /** + * Sets a user implementation proxy for making connections to feature-flag.co + * + * @param proxy the proxy + * @return the builder + */ public HttpConfigurationBuilder proxy(Proxy proxy) { this.proxy = proxy; return this; } + /** + * Sets a basic authentication strategy for use with an HTTP proxy. This has no effect unless a proxy was specified + * + * @param userName a string + * @param password a string + * @return the builder + */ public HttpConfigurationBuilder passwordAuthenticator(String userName, String password) { this.authenticator = Utils.buildAuthenticator(userName, password); return this; } + /** + * Sets a user implementation authentication strategy for use with a proxy. This has no effect unless a proxy was specified + * + * @param authenticator the {@link Authenticator} + * @return the builder + */ public HttpConfigurationBuilder authenticator(Authenticator authenticator) { this.authenticator = authenticator; return this; } + /** + * Specifies a custom socket configuration for HTTP connections to feature-flag.co. + * + * @param factory the socket factory + * @return the builder + */ public HttpConfigurationBuilder socketFactory(SocketFactory factory) { this.socketFactory = factory; return this; } - public HttpConfigurationBuilder sslSocketFactory(SSLSocketFactory factory) { + /** + * Specifies a custom security configuration for HTTPS connections to feature-flag.co. + * + * @param factory the SSL socket factory + * @param trustManager the trust manager + * @return the builder + */ + public HttpConfigurationBuilder sslSocketFactory(SSLSocketFactory factory, X509TrustManager trustManager) { this.sslSocketFactory = factory; - return this; - } - - public HttpConfigurationBuilder trustManager(X509TrustManager trustManager) { this.x509TrustManager = trustManager; return this; } diff --git a/src/main/java/co/featureflags/server/exterior/InsightEventSender.java b/src/main/java/co/featureflags/server/exterior/InsightEventSender.java index 3e42df8..08c51c1 100644 --- a/src/main/java/co/featureflags/server/exterior/InsightEventSender.java +++ b/src/main/java/co/featureflags/server/exterior/InsightEventSender.java @@ -1,7 +1,22 @@ package co.featureflags.server.exterior; +import co.featureflags.server.Factory; + import java.io.Closeable; +/** + * interface for a component that can deliver preformatted event data. + *

+ * The standard implementation is {@link Factory#insightProcessorFactory()} + * + * @see Factory + */ public interface InsightEventSender extends Closeable { + /** + * deliver an event data payload. + * + * @param eventUrl the configured events endpoint url + * @param json the preformatted JSON data, as a string + */ void sendEvent(String eventUrl, String json); } diff --git a/src/main/java/co/featureflags/server/exterior/InsightEventSenderFactory.java b/src/main/java/co/featureflags/server/exterior/InsightEventSenderFactory.java index ab4b2e5..5d79fc4 100644 --- a/src/main/java/co/featureflags/server/exterior/InsightEventSenderFactory.java +++ b/src/main/java/co/featureflags/server/exterior/InsightEventSenderFactory.java @@ -1,5 +1,16 @@ package co.featureflags.server.exterior; +/** + * Interface for a factory that creates an implementation of {@link InsightEventSender}. + * + * @see co.featureflags.server.Factory + */ public interface InsightEventSenderFactory { + /** + * create an implementation of {@link InsightEventSender}. + * + * @param context allows access to the client configuration + * @return an {@link InsightEventSender} + */ InsightEventSender createInsightEventSender(Context context); } \ No newline at end of file diff --git a/src/main/java/co/featureflags/server/exterior/InsightProcessor.java b/src/main/java/co/featureflags/server/exterior/InsightProcessor.java index c4c0a96..46d3c7e 100644 --- a/src/main/java/co/featureflags/server/exterior/InsightProcessor.java +++ b/src/main/java/co/featureflags/server/exterior/InsightProcessor.java @@ -1,11 +1,37 @@ package co.featureflags.server.exterior; +import co.featureflags.server.Factory; import co.featureflags.server.InsightTypes; import java.io.Closeable; +/** + * Interface for a component to send analytics events. + *

+ * The standard implementations are: + *

    + *
  • {@link Factory#insightProcessorFactory()} (the default), which + * sends events to feature-flag.co + *
  • {@link Factory#noInsightInOffline()} which does nothing + * (on the assumption that another process will send the events); + *
+ * + * @see Factory + */ public interface InsightProcessor extends Closeable { + + /** + * Records an event asynchronously. + * + * @param event + */ void send(InsightTypes.Event event); + /** + * Specifies that any buffered events should be sent as soon as possible, rather than waiting + * for the next flush interval. This method is asynchronous, so events still may not be sent + * until a later time. However, calling {@link Closeable#close()} will synchronously deliver + * any events that were not yet delivered prior to shutting down. + */ void flush(); } diff --git a/src/main/java/co/featureflags/server/exterior/InsightProcessorFactory.java b/src/main/java/co/featureflags/server/exterior/InsightProcessorFactory.java index cbf7e61..08e1695 100644 --- a/src/main/java/co/featureflags/server/exterior/InsightProcessorFactory.java +++ b/src/main/java/co/featureflags/server/exterior/InsightProcessorFactory.java @@ -1,7 +1,19 @@ package co.featureflags.server.exterior; +/** + * Interface for a factory that creates an implementation of {@link InsightProcessor}. + * + * @see co.featureflags.server.Factory + */ + public interface InsightProcessorFactory { + /** + * creates an implementation of {@link InsightProcessor} + * + * @param context allows access to the client configuration + * @return an {@link InsightProcessor} + */ InsightProcessor createInsightProcessor(Context context); } diff --git a/src/main/java/co/featureflags/server/exterior/UpdateProcessor.java b/src/main/java/co/featureflags/server/exterior/UpdateProcessor.java index c4a9f5e..2d8e1d2 100644 --- a/src/main/java/co/featureflags/server/exterior/UpdateProcessor.java +++ b/src/main/java/co/featureflags/server/exterior/UpdateProcessor.java @@ -1,11 +1,34 @@ package co.featureflags.server.exterior; +import co.featureflags.server.Factory; + import java.io.Closeable; import java.util.concurrent.Future; +/** + * Interface to receive updates to feature flags, user segments, and anything + * else that might come from feature-flag.co, and passes them to a {@link DataStorage}. + *

+ * The standard implementations are: + *

    + *
  • {@link Factory#streamingBuilder()} (the default), which + * maintains a streaming connection to LaunchDarkly; + *
  • {@link Factory#externalOnlyDataUpdate()}, which does nothing + * (on the assumption that another process will update the data store); + *
+ */ public interface UpdateProcessor extends Closeable { + /** + * Starts the client update processing. + * + * @return {@link Future}'s completion status indicates the client has been initialized. + */ Future start(); + /** + * Returns true once the client has been initialized and will never return false again. + * @return true if the client has been initialized + */ boolean isInitialized(); } diff --git a/src/main/java/co/featureflags/server/exterior/UpdateProcessorFactory.java b/src/main/java/co/featureflags/server/exterior/UpdateProcessorFactory.java index bd89452..7c7352e 100644 --- a/src/main/java/co/featureflags/server/exterior/UpdateProcessorFactory.java +++ b/src/main/java/co/featureflags/server/exterior/UpdateProcessorFactory.java @@ -2,6 +2,18 @@ import co.featureflags.server.Status; +/** + * Interface for a factory that creates some implementation of {@link UpdateProcessor}. + * + * @see co.featureflags.server.Factory + */ public interface UpdateProcessorFactory { + /** + * Creates an implementation instance. + * + * @param context allows access to the client configuration + * @param dataUpdator the {@link co.featureflags.server.Status.DataUpdator} which pushes data into the {@link DataStorage} + * @return an {@link UpdateProcessor} + */ UpdateProcessor createUpdateProcessor(Context context, Status.DataUpdator dataUpdator); } diff --git a/src/test/java/co/featureflags/server/Demos.java b/src/test/java/co/featureflags/server/Demos.java index 3c58e0a..63616ad 100644 --- a/src/test/java/co/featureflags/server/Demos.java +++ b/src/test/java/co/featureflags/server/Demos.java @@ -1,6 +1,6 @@ package co.featureflags.server; -import co.featureflags.commons.json.JsonHelper; +import co.featureflags.commons.model.AllFlagStates; import co.featureflags.commons.model.EvalDetail; import co.featureflags.commons.model.FFCUser; import co.featureflags.commons.model.FlagState; @@ -10,13 +10,12 @@ import co.featureflags.server.exterior.FFCClient; import co.featureflags.server.exterior.HttpConfig; import com.google.common.io.Resources; -import com.google.common.reflect.TypeToken; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; -import java.util.List; +import java.util.Random; import java.util.Scanner; abstract class Demos { @@ -38,6 +37,7 @@ public static void main(String[] args) throws IOException { FFCConfig config = new FFCConfig.Builder() + .offline(false) .updateProcessorFactory(streamingBuilder) .insightProcessorFactory(insightProcessorBuilder) .build(); @@ -57,11 +57,16 @@ public static void main(String[] args) throws IOException { try { String[] words = line.split("/"); user = new FFCUser.Builder(words[0]).build(); + Instant start = Instant.now(); FlagState res = client.variationDetail(words[1], user, "Not Found"); Instant end = Instant.now(); System.out.println("result is " + res); monitoringPerf("evaluate", start, end); + Random rd = new Random(); + if (rd.nextBoolean()) { + client.trackMetric(user, "click2pay"); + } } catch (Exception e) { break; } @@ -147,7 +152,7 @@ public static void main(String[] args) throws IOException { String jsonBody = params.jsonfy(); System.out.println(jsonBody); String jsonResult = sender.postJson("http://localhost:8080/api/public/feature-flag/variation", jsonBody); - EvalDetail res = EvalDetail.fromJson(jsonResult, String.class); + FlagState res = FlagState.fromJson(jsonResult, String.class); System.out.println("result is " + res); } catch (Exception e) { break; @@ -182,9 +187,8 @@ public static void main(String[] args) throws IOException { String jsonBody = params.jsonfy(); System.out.println(jsonBody); String jsonResult = sender.postJson("http://localhost:8080/api/public/feature-flag/variations", jsonBody); - List> res = JsonHelper.deserialize(jsonResult, new TypeToken>>() { - }.getType()); - for (EvalDetail ed : res) { + AllFlagStates res = AllFlagStates.fromJson(jsonResult, String.class); + for (EvalDetail ed : res.getData()) { System.out.println(ed); } } catch (Exception e) {