Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0b95531
feat: add experimentation support
Zaimwa9 Sep 22, 2026
431a867
fix: settle every event batch and never ship transient traits
Zaimwa9 Sep 22, 2026
bc0a81e
fix: allow a custom events URI without enabling events
Zaimwa9 Sep 22, 2026
34ca69d
refactor: stop publishing EventProcessor internals as public API
Zaimwa9 Sep 23, 2026
d2fee34
fix: serialise each event when it is buffered, not with its batch
Zaimwa9 Sep 23, 2026
66daa74
fix: bound in-flight event batches and log events the API rejects
Zaimwa9 Sep 23, 2026
a717dc6
fix: close the event processor's shutdown and startup races
Zaimwa9 Sep 23, 2026
9b91185
fix: reject blank event names and unbounded event buffer settings
Zaimwa9 Sep 23, 2026
ae482c1
fix: serve the default flag when identity flags time out in getExperi…
Zaimwa9 Sep 23, 2026
07c2472
fix: cap in-flight events, not batches, and rate-limit the drop log
Zaimwa9 Sep 23, 2026
5972383
fix: bound close() by the worst case of a batch under the real timeouts
Zaimwa9 Sep 23, 2026
ac499a2
test: pin the closed re-check under the buffer lock, drop a vacuous test
Zaimwa9 Sep 23, 2026
d4e6185
fix: keep a self-referencing trait map from escaping trackEvent as an…
Zaimwa9 Sep 23, 2026
9213caa
refactor: trim comments
Zaimwa9 Sep 24, 2026
05d2fc2
fix: give each client its own event processor
Zaimwa9 Sep 24, 2026
892e05c
fix: let an injected event processor back only one client
Zaimwa9 Sep 25, 2026
2a36178
fix: hold the in-flight cap and retry any 5xx once
Zaimwa9 Sep 29, 2026
16e7c5d
test: consolidate event and experiment tests
Zaimwa9 Sep 29, 2026
b9de038
test: keep FlagTest on JUnit 4
Zaimwa9 Sep 29, 2026
52c1792
refactor: bound close() by a fixed timeout
Zaimwa9 Sep 29, 2026
bf34335
fix: claim the event processor before starting polling
Zaimwa9 Sep 29, 2026
4266bdb
fix: derive the close() bound from the client's timeouts
Zaimwa9 Sep 29, 2026
89106e5
fix: send null traits when an event has none
Zaimwa9 Sep 29, 2026
a1927ee
fix: keep custom headers off the events request
Zaimwa9 Sep 29, 2026
e97fa24
Revert "fix: keep custom headers off the events request"
Zaimwa9 Sep 29, 2026
559b270
fix: keep custom headers off the events request
Zaimwa9 Sep 29, 2026
09e83b7
fix: stop event requests from outliving close()
Zaimwa9 Sep 29, 2026
77ca614
fix: carry events over the in-flight cap instead of dropping them
Zaimwa9 Sep 29, 2026
5343b9e
fix: settle batches atomically and bound events calls when a timeout …
Zaimwa9 Sep 29, 2026
324ae9f
fix: send waiting events once in-flight capacity frees up
Zaimwa9 Sep 29, 2026
29fd232
fix: settle pending flush futures when close() force-stops
Zaimwa9 Sep 29, 2026
deea76e
refactor: cap in-flight event batches instead of in-flight events
Zaimwa9 Sep 29, 2026
7baec55
fix: make flush wait for events buffered behind the in-flight limit
Zaimwa9 Sep 29, 2026
45804d2
fix: send the SDK's environment key on events when a custom one is set
Zaimwa9 Sep 29, 2026
ddab0cf
fix: close the previous event processor when a builder is rebuilt
Zaimwa9 Sep 29, 2026
5e77a76
fix: build the exposure dedupe key from its fields instead of a joine…
Zaimwa9 Sep 29, 2026
960609a
fix: bound events waiting behind the in-flight limit at 1,000
Zaimwa9 Sep 29, 2026
02c8463
fix: forget a dropped exposure's dedupe key
Zaimwa9 Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 236 additions & 7 deletions src/main/java/com/flagsmith/FlagsmithClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,27 @@
import com.flagsmith.interfaces.FlagsmithSdk;
import com.flagsmith.mappers.EngineMappers;
import com.flagsmith.models.BaseFlag;
import com.flagsmith.models.ExperimentMetadata;
import com.flagsmith.models.Flag;
import com.flagsmith.models.Flags;
import com.flagsmith.models.Segment;
import com.flagsmith.models.SegmentMetadata;
import com.flagsmith.threads.EventProcessor;
import com.flagsmith.threads.PollingManager;
import com.flagsmith.utils.ModelUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -39,6 +47,9 @@ public class FlagsmithClient {
private FlagsmithSdk flagsmithSdk;
private EvaluationContext evaluationContext;
private PollingManager pollingManager;
@Getter(AccessLevel.PACKAGE)
@Setter(AccessLevel.NONE)
private EventProcessor eventProcessor;

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

/**
* As {@link #getExperimentFlag(String, String, Map)}, with no traits.
*
* @param featureName feature name
* @param identifier identifier string
* @return the flag for the given feature
* @throws FlagsmithRuntimeError when events are not enabled
* @throws FlagsmithApiError when identity flags are unavailable and no default flag handler
* is configured
*/
public BaseFlag getExperimentFlag(String featureName, String identifier)
throws FlagsmithClientError {
return getExperimentFlag(featureName, identifier, new HashMap<>());
}

/**
* Get an identity's flag, recording one {@code $flag_exposure} event if the identity is enrolled
* in a running experiment on it. Only remote evaluation carries experiment metadata, so local
* evaluation and offline mode record no exposure.
*
* @param featureName feature name
* @param identifier identifier string
* @param traits a map of trait keys to trait values
* @return the flag for the given feature
* @throws FlagsmithRuntimeError when events are not enabled
* @throws FlagsmithApiError when identity flags are unavailable and no default flag handler
* is configured
*/
public BaseFlag getExperimentFlag(
String featureName, String identifier, Map<String, Object> traits)
throws FlagsmithClientError {
requireEventProcessor("get experiment flags");

Flags flags = getIdentityFlags(identifier, traits);

if (flags == null) {
FlagsmithFlagDefaults defaults = getConfig().getFlagsmithFlagDefaults();
if (defaults == null) {
throw new FlagsmithApiError("Failed to get feature flags.");
}
logger.info("Not recording an exposure for feature {}: identity flags are unavailable, so "
+ "the default flag handler served it.", featureName);
return defaults.evaluateDefaultFlag(featureName);
}

BaseFlag flag = flags.getFlag(featureName);

if (!(flag instanceof Flag)) {
logger.info("Not recording an exposure for feature {}: served by the default flag handler.",
featureName);
return flag;
}

if (!Boolean.TRUE.equals(flag.getEnabled())) {
Comment thread
Zaimwa9 marked this conversation as resolved.
logger.info("Not recording an exposure for feature {}: the flag is disabled.", featureName);
return flag;
}

ExperimentMetadata experiment = ((Flag) flag).getExperiment();
if (experiment == null || !Boolean.TRUE.equals(experiment.getInExperiment())) {
logger.info("Not recording an exposure for feature {}: the identity is not enrolled in a "
+ "running experiment.", featureName);
return flag;
}

Map<String, Object> metadata = new HashMap<>();
metadata.put("experiment_id", experiment.getId());
trackExposureEvent(featureName, identifier, ((Flag) flag).getVariant(), traits, metadata);

return flag;
}

/**
* Record a custom event.
*
* @param event event name
* @throws FlagsmithRuntimeError when events are not enabled
* @throws IllegalArgumentException when the event name is blank or starts with "$"
*/
public void trackEvent(String event) {
trackEvent(event, null, null, null, null);
}

/**
* Record a custom event for an identity.
*
* @param event event name
* @param identifier identifier string
* @throws FlagsmithRuntimeError when events are not enabled
* @throws IllegalArgumentException when the event name is blank or starts with "$"
*/
public void trackEvent(String event, String identifier) {
trackEvent(event, identifier, null, null, null);
}

/**
* Record a custom event for an identity, with a value, traits and metadata.
*
* @param event event name
* @param identifier identifier string
* @param value event value, stringified before sending
* @param traits a map of trait keys to trait values
* @param metadata a map of metadata to attach to the event
* @throws FlagsmithRuntimeError when events are not enabled
* @throws IllegalArgumentException when the event name is blank or starts with "$"
*/
public void trackEvent(String event, String identifier, Object value,
Map<String, Object> traits, Map<String, Object> metadata) {
EventProcessor processor = requireEventProcessor("track events");

if (StringUtils.isBlank(event)) {
throw new IllegalArgumentException("An event name is required.");
}
if (event.startsWith("$")) {
throw new IllegalArgumentException("Event names starting with \"$\" are reserved; use "
+ "trackExposureEvent to record \"" + EventProcessor.FLAG_EXPOSURE_EVENT + "\".");
}

processor.trackEvent(event, identifier, value, traits, metadata);
}

/**
* Record a {@code $flag_exposure} event. Skipped, with a log line, when the identifier is
* blank.
*
* @param featureName feature the identity was exposed to
* @param identifier identifier string
* @param value variant the identity was bucketed into
* @throws FlagsmithRuntimeError when events are not enabled
* @throws IllegalArgumentException when the feature name is blank
*/
public void trackExposureEvent(String featureName, String identifier, Object value) {
trackExposureEvent(featureName, identifier, value, null, null);
}

/**
* Record a {@code $flag_exposure} event, with traits and metadata. Skipped, with a log line,
* when the identifier is blank.
*
* @param featureName feature the identity was exposed to
* @param identifier identifier string
* @param value variant the identity was bucketed into
* @param traits a map of trait keys to trait values
* @param metadata a map of metadata to attach to the event
* @throws FlagsmithRuntimeError when events are not enabled
* @throws IllegalArgumentException when the feature name is blank
*/
public void trackExposureEvent(String featureName, String identifier, Object value,
Map<String, Object> traits, Map<String, Object> metadata) {
EventProcessor processor = requireEventProcessor("track exposure events");

if (StringUtils.isBlank(featureName)) {
throw new IllegalArgumentException("An exposure requires a feature name.");
}
if (StringUtils.isBlank(identifier)) {
logger.info("Not sending {} for feature {}: an exposure requires an identifier.",
EventProcessor.FLAG_EXPOSURE_EVENT, featureName);
return;
}

processor.trackExposureEvent(featureName, identifier, value, traits, metadata);
}

/**
* Send buffered events now.
*
* @return a future completing once every event buffered so far has been sent or dropped, already
* completed when events are not enabled
*/
public CompletableFuture<Void> flushEvents() {
if (eventProcessor == null) {
return CompletableFuture.completedFuture(null);
}

return eventProcessor.flush();
}

/**
* Should be called when terminating the client to clean up any resources that
* need cleaning up.
Expand All @@ -205,9 +393,23 @@ public void close() {
if (pollingManager != null) {
pollingManager.stopPolling();
}

if (eventProcessor != null) {
eventProcessor.close();
}

flagsmithSdk.close();
}

private EventProcessor requireEventProcessor(String action) {
if (eventProcessor == null) {
throw new FlagsmithRuntimeError(
"Events must be enabled to " + action + ". Use withEnableEvents(true).");
}

return eventProcessor;
}

private Flags getEnvironmentFlagsFromEvaluationContext() throws FlagsmithClientError {
if (evaluationContext == null) {
if (getConfig().getFlagsmithFlagDefaults() == null) {
Expand Down Expand Up @@ -446,7 +648,7 @@ public Builder withApiUrl(String apiUrl) {
}

/**
* Add custom HTTP headers to the calls.
* Add custom HTTP headers to the Flags API calls. They are not sent to the events API.
*
* @param customHeaders headers.
* @return the Builder
Expand Down Expand Up @@ -501,6 +703,9 @@ public FlagsmithClient build() {
if (configuration.getOfflineHandler() == null) {
throw new FlagsmithRuntimeError("Offline handler must be provided to use offline mode.");
}
if (configuration.getEnableEvents()) {
throw new FlagsmithRuntimeError("Events cannot be enabled in offline mode.");
}
}

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

if (configuration.getOfflineHandler() != null) {
if (configuration.getFlagsmithFlagDefaults() != null) {
throw new FlagsmithRuntimeError(
"Cannot use both default flag handler and offline handler.");
}
client.evaluationContext = EngineMappers.mapEnvironmentToContext(
configuration.getOfflineHandler().getEnvironment());
}

EventProcessor processor = null;
if (configuration.getEnableEvents()) {
processor = configuration.getEventProcessor() != null
? configuration.getEventProcessor()
: new EventProcessor(
configuration.getHttpClient(),
configuration.getEventsUri(),
configuration.getEventsMaxBufferItems(),
configuration.getEventsFlushIntervalMillis());
processor.claim();
Comment thread
Zaimwa9 marked this conversation as resolved.
}

if (configuration.getEnableLocalEvaluation()) {
if (this.pollingManager != null) {
client.pollingManager = pollingManager;
} else {
Expand All @@ -548,13 +776,14 @@ public FlagsmithClient build() {
client.pollingManager.startPolling();
}

if (configuration.getOfflineHandler() != null) {
if (configuration.getFlagsmithFlagDefaults() != null) {
throw new FlagsmithRuntimeError(
"Cannot use both default flag handler and offline handler.");
if (processor != null) {
if (client.eventProcessor != null) {
client.eventProcessor.close();
}
client.evaluationContext = EngineMappers.mapEnvironmentToContext(
configuration.getOfflineHandler().getEnvironment());
processor.setApi(client.flagsmithSdk);
processor.setLogger(client.logger);
processor.start();
client.eventProcessor = processor;
Comment thread
Zaimwa9 marked this conversation as resolved.
}

return this.client;
Expand Down
Loading
Loading