1313import com .flagsmith .interfaces .FlagsmithSdk ;
1414import com .flagsmith .mappers .EngineMappers ;
1515import com .flagsmith .models .BaseFlag ;
16+ import com .flagsmith .models .ExperimentMetadata ;
17+ import com .flagsmith .models .Flag ;
1618import com .flagsmith .models .Flags ;
1719import com .flagsmith .models .Segment ;
1820import com .flagsmith .models .SegmentMetadata ;
21+ import com .flagsmith .threads .EventProcessor ;
1922import com .flagsmith .threads .PollingManager ;
2023import com .flagsmith .utils .ModelUtils ;
2124import java .util .HashMap ;
2225import java .util .List ;
2326import java .util .Map ;
2427import java .util .Objects ;
28+ import java .util .concurrent .CompletableFuture ;
2529import java .util .function .Function ;
2630import java .util .stream .Collectors ;
31+ import lombok .AccessLevel ;
2732import lombok .Data ;
33+ import lombok .Getter ;
2834import lombok .NonNull ;
35+ import lombok .Setter ;
36+ import org .apache .commons .lang3 .StringUtils ;
2937import org .slf4j .Logger ;
3038import 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