Skip to content

Commit c9dadea

Browse files
authored
feat: experimentation support (#226)
1 parent a0c4f54 commit c9dadea

15 files changed

Lines changed: 2426 additions & 10 deletions

‎src/main/java/com/flagsmith/FlagsmithClient.java‎

Lines changed: 236 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,27 @@
1313
import com.flagsmith.interfaces.FlagsmithSdk;
1414
import com.flagsmith.mappers.EngineMappers;
1515
import com.flagsmith.models.BaseFlag;
16+
import com.flagsmith.models.ExperimentMetadata;
17+
import com.flagsmith.models.Flag;
1618
import com.flagsmith.models.Flags;
1719
import com.flagsmith.models.Segment;
1820
import com.flagsmith.models.SegmentMetadata;
21+
import com.flagsmith.threads.EventProcessor;
1922
import com.flagsmith.threads.PollingManager;
2023
import com.flagsmith.utils.ModelUtils;
2124
import java.util.HashMap;
2225
import java.util.List;
2326
import java.util.Map;
2427
import java.util.Objects;
28+
import java.util.concurrent.CompletableFuture;
2529
import java.util.function.Function;
2630
import java.util.stream.Collectors;
31+
import lombok.AccessLevel;
2732
import lombok.Data;
33+
import lombok.Getter;
2834
import lombok.NonNull;
35+
import lombok.Setter;
36+
import org.apache.commons.lang3.StringUtils;
2937
import org.slf4j.Logger;
3038
import org.slf4j.LoggerFactory;
3139

@@ -39,6 +47,9 @@ public class FlagsmithClient {
3947
private FlagsmithSdk flagsmithSdk;
4048
private EvaluationContext evaluationContext;
4149
private PollingManager pollingManager;
50+
@Getter(AccessLevel.PACKAGE)
51+
@Setter(AccessLevel.NONE)
52+
private EventProcessor eventProcessor;
4253

4354
private FlagsmithClient() {
4455
}
@@ -197,6 +208,183 @@ public List<Segment> getIdentitySegments(String identifier, Map<String, Object>
197208
}).filter(Objects::nonNull).collect(Collectors.toList());
198209
}
199210

211+
/**
212+
* As {@link #getExperimentFlag(String, String, Map)}, with no traits.
213+
*
214+
* @param featureName feature name
215+
* @param identifier identifier string
216+
* @return the flag for the given feature
217+
* @throws FlagsmithRuntimeError when events are not enabled
218+
* @throws FlagsmithApiError when identity flags are unavailable and no default flag handler
219+
* is configured
220+
*/
221+
public BaseFlag getExperimentFlag(String featureName, String identifier)
222+
throws FlagsmithClientError {
223+
return getExperimentFlag(featureName, identifier, new HashMap<>());
224+
}
225+
226+
/**
227+
* Get an identity's flag, recording one {@code $flag_exposure} event if the identity is enrolled
228+
* in a running experiment on it. Only remote evaluation carries experiment metadata, so local
229+
* evaluation and offline mode record no exposure.
230+
*
231+
* @param featureName feature name
232+
* @param identifier identifier string
233+
* @param traits a map of trait keys to trait values
234+
* @return the flag for the given feature
235+
* @throws FlagsmithRuntimeError when events are not enabled
236+
* @throws FlagsmithApiError when identity flags are unavailable and no default flag handler
237+
* is configured
238+
*/
239+
public BaseFlag getExperimentFlag(
240+
String featureName, String identifier, Map<String, Object> traits)
241+
throws FlagsmithClientError {
242+
requireEventProcessor("get experiment flags");
243+
244+
Flags flags = getIdentityFlags(identifier, traits);
245+
246+
if (flags == null) {
247+
FlagsmithFlagDefaults defaults = getConfig().getFlagsmithFlagDefaults();
248+
if (defaults == null) {
249+
throw new FlagsmithApiError("Failed to get feature flags.");
250+
}
251+
logger.info("Not recording an exposure for feature {}: identity flags are unavailable, so "
252+
+ "the default flag handler served it.", featureName);
253+
return defaults.evaluateDefaultFlag(featureName);
254+
}
255+
256+
BaseFlag flag = flags.getFlag(featureName);
257+
258+
if (!(flag instanceof Flag)) {
259+
logger.info("Not recording an exposure for feature {}: served by the default flag handler.",
260+
featureName);
261+
return flag;
262+
}
263+
264+
if (!Boolean.TRUE.equals(flag.getEnabled())) {
265+
logger.info("Not recording an exposure for feature {}: the flag is disabled.", featureName);
266+
return flag;
267+
}
268+
269+
ExperimentMetadata experiment = ((Flag) flag).getExperiment();
270+
if (experiment == null || !Boolean.TRUE.equals(experiment.getInExperiment())) {
271+
logger.info("Not recording an exposure for feature {}: the identity is not enrolled in a "
272+
+ "running experiment.", featureName);
273+
return flag;
274+
}
275+
276+
Map<String, Object> metadata = new HashMap<>();
277+
metadata.put("experiment_id", experiment.getId());
278+
trackExposureEvent(featureName, identifier, ((Flag) flag).getVariant(), traits, metadata);
279+
280+
return flag;
281+
}
282+
283+
/**
284+
* Record a custom event.
285+
*
286+
* @param event event name
287+
* @throws FlagsmithRuntimeError when events are not enabled
288+
* @throws IllegalArgumentException when the event name is blank or starts with "$"
289+
*/
290+
public void trackEvent(String event) {
291+
trackEvent(event, null, null, null, null);
292+
}
293+
294+
/**
295+
* Record a custom event for an identity.
296+
*
297+
* @param event event name
298+
* @param identifier identifier string
299+
* @throws FlagsmithRuntimeError when events are not enabled
300+
* @throws IllegalArgumentException when the event name is blank or starts with "$"
301+
*/
302+
public void trackEvent(String event, String identifier) {
303+
trackEvent(event, identifier, null, null, null);
304+
}
305+
306+
/**
307+
* Record a custom event for an identity, with a value, traits and metadata.
308+
*
309+
* @param event event name
310+
* @param identifier identifier string
311+
* @param value event value, stringified before sending
312+
* @param traits a map of trait keys to trait values
313+
* @param metadata a map of metadata to attach to the event
314+
* @throws FlagsmithRuntimeError when events are not enabled
315+
* @throws IllegalArgumentException when the event name is blank or starts with "$"
316+
*/
317+
public void trackEvent(String event, String identifier, Object value,
318+
Map<String, Object> traits, Map<String, Object> metadata) {
319+
EventProcessor processor = requireEventProcessor("track events");
320+
321+
if (StringUtils.isBlank(event)) {
322+
throw new IllegalArgumentException("An event name is required.");
323+
}
324+
if (event.startsWith("$")) {
325+
throw new IllegalArgumentException("Event names starting with \"$\" are reserved; use "
326+
+ "trackExposureEvent to record \"" + EventProcessor.FLAG_EXPOSURE_EVENT + "\".");
327+
}
328+
329+
processor.trackEvent(event, identifier, value, traits, metadata);
330+
}
331+
332+
/**
333+
* Record a {@code $flag_exposure} event. Skipped, with a log line, when the identifier is
334+
* blank.
335+
*
336+
* @param featureName feature the identity was exposed to
337+
* @param identifier identifier string
338+
* @param value variant the identity was bucketed into
339+
* @throws FlagsmithRuntimeError when events are not enabled
340+
* @throws IllegalArgumentException when the feature name is blank
341+
*/
342+
public void trackExposureEvent(String featureName, String identifier, Object value) {
343+
trackExposureEvent(featureName, identifier, value, null, null);
344+
}
345+
346+
/**
347+
* Record a {@code $flag_exposure} event, with traits and metadata. Skipped, with a log line,
348+
* when the identifier is blank.
349+
*
350+
* @param featureName feature the identity was exposed to
351+
* @param identifier identifier string
352+
* @param value variant the identity was bucketed into
353+
* @param traits a map of trait keys to trait values
354+
* @param metadata a map of metadata to attach to the event
355+
* @throws FlagsmithRuntimeError when events are not enabled
356+
* @throws IllegalArgumentException when the feature name is blank
357+
*/
358+
public void trackExposureEvent(String featureName, String identifier, Object value,
359+
Map<String, Object> traits, Map<String, Object> metadata) {
360+
EventProcessor processor = requireEventProcessor("track exposure events");
361+
362+
if (StringUtils.isBlank(featureName)) {
363+
throw new IllegalArgumentException("An exposure requires a feature name.");
364+
}
365+
if (StringUtils.isBlank(identifier)) {
366+
logger.info("Not sending {} for feature {}: an exposure requires an identifier.",
367+
EventProcessor.FLAG_EXPOSURE_EVENT, featureName);
368+
return;
369+
}
370+
371+
processor.trackExposureEvent(featureName, identifier, value, traits, metadata);
372+
}
373+
374+
/**
375+
* Send buffered events now.
376+
*
377+
* @return a future completing once every event buffered so far has been sent or dropped, already
378+
* completed when events are not enabled
379+
*/
380+
public CompletableFuture<Void> flushEvents() {
381+
if (eventProcessor == null) {
382+
return CompletableFuture.completedFuture(null);
383+
}
384+
385+
return eventProcessor.flush();
386+
}
387+
200388
/**
201389
* Should be called when terminating the client to clean up any resources that
202390
* need cleaning up.
@@ -205,9 +393,23 @@ public void close() {
205393
if (pollingManager != null) {
206394
pollingManager.stopPolling();
207395
}
396+
397+
if (eventProcessor != null) {
398+
eventProcessor.close();
399+
}
400+
208401
flagsmithSdk.close();
209402
}
210403

404+
private EventProcessor requireEventProcessor(String action) {
405+
if (eventProcessor == null) {
406+
throw new FlagsmithRuntimeError(
407+
"Events must be enabled to " + action + ". Use withEnableEvents(true).");
408+
}
409+
410+
return eventProcessor;
411+
}
412+
211413
private Flags getEnvironmentFlagsFromEvaluationContext() throws FlagsmithClientError {
212414
if (evaluationContext == null) {
213415
if (getConfig().getFlagsmithFlagDefaults() == null) {
@@ -446,7 +648,7 @@ public Builder withApiUrl(String apiUrl) {
446648
}
447649

448650
/**
449-
* Add custom HTTP headers to the calls.
651+
* Add custom HTTP headers to the Flags API calls. They are not sent to the events API.
450652
*
451653
* @param customHeaders headers.
452654
* @return the Builder
@@ -501,6 +703,9 @@ public FlagsmithClient build() {
501703
if (configuration.getOfflineHandler() == null) {
502704
throw new FlagsmithRuntimeError("Offline handler must be provided to use offline mode.");
503705
}
706+
if (configuration.getEnableEvents()) {
707+
throw new FlagsmithRuntimeError("Events cannot be enabled in offline mode.");
708+
}
504709
}
505710

506711
if (this.flagsmithApiWrapper != null) {
@@ -536,7 +741,30 @@ public FlagsmithClient build() {
536741
"In order to use local evaluation, please generate a server key "
537742
+ "in the environment settings page.");
538743
}
744+
}
539745

746+
if (configuration.getOfflineHandler() != null) {
747+
if (configuration.getFlagsmithFlagDefaults() != null) {
748+
throw new FlagsmithRuntimeError(
749+
"Cannot use both default flag handler and offline handler.");
750+
}
751+
client.evaluationContext = EngineMappers.mapEnvironmentToContext(
752+
configuration.getOfflineHandler().getEnvironment());
753+
}
754+
755+
EventProcessor processor = null;
756+
if (configuration.getEnableEvents()) {
757+
processor = configuration.getEventProcessor() != null
758+
? configuration.getEventProcessor()
759+
: new EventProcessor(
760+
configuration.getHttpClient(),
761+
configuration.getEventsUri(),
762+
configuration.getEventsMaxBufferItems(),
763+
configuration.getEventsFlushIntervalMillis());
764+
processor.claim();
765+
}
766+
767+
if (configuration.getEnableLocalEvaluation()) {
540768
if (this.pollingManager != null) {
541769
client.pollingManager = pollingManager;
542770
} else {
@@ -548,13 +776,14 @@ public FlagsmithClient build() {
548776
client.pollingManager.startPolling();
549777
}
550778

551-
if (configuration.getOfflineHandler() != null) {
552-
if (configuration.getFlagsmithFlagDefaults() != null) {
553-
throw new FlagsmithRuntimeError(
554-
"Cannot use both default flag handler and offline handler.");
779+
if (processor != null) {
780+
if (client.eventProcessor != null) {
781+
client.eventProcessor.close();
555782
}
556-
client.evaluationContext = EngineMappers.mapEnvironmentToContext(
557-
configuration.getOfflineHandler().getEnvironment());
783+
processor.setApi(client.flagsmithSdk);
784+
processor.setLogger(client.logger);
785+
processor.start();
786+
client.eventProcessor = processor;
558787
}
559788

560789
return this.client;

0 commit comments

Comments
 (0)