The objects must be serializable to a JSON object via Gson (not primitive or array)
*
- * @param distinctId Unique ID of the target in your database. May not be empty.
- * @param properties Properties to set (including overwriting previous values) on the person profile
+ * @param distinctId Unique ID of the target in your database. May not be empty.
+ * @param properties Properties to set (including overwriting previous values) on the person profile
* @param propertiesSetOnce Properties to set only if missing on the person profile
*/
public static void set(@NotNull String distinctId, @Nullable Object properties, @Nullable Object propertiesSetOnce) {
@@ -169,7 +169,7 @@ public static void set(@NotNull String distinctId, @Nullable Object properties)
* Alias the given distinct ID to the given alias.
*
* @param distinctId Unique ID of the target in your database. May not be empty.
- * @param alias Alias to set for the distinct ID. May not be empty.
+ * @param alias Alias to set for the distinct ID. May not be empty.
*/
public static void alias(@NotNull String distinctId, @NotNull String alias) {
getClient().alias(distinctId, alias);
@@ -178,8 +178,8 @@ public static void alias(@NotNull String distinctId, @NotNull String alias) {
/**
* Assign the given properties to the given group (type & key).
*
- * @param type Group type. Must not be empty
- * @param key Group key. Must not be empty
+ * @param type Group type. Must not be empty
+ * @param key Group key. Must not be empty
* @param properties Properties to set (including overwriting previous values) on the group
*/
public static void groupIdentify(@NotNull String type, @NotNull String key, @NotNull Map properties) {
@@ -191,8 +191,8 @@ public static void groupIdentify(@NotNull String type, @NotNull String key, @Not
*
* The object must be serializable to a JSON object via Gson (not primitive or array)
*
- * @param type Group type. Must not be empty
- * @param key Group key. Must not be empty
+ * @param type Group type. Must not be empty
+ * @param key Group key. Must not be empty
* @param properties Properties to set (including overwriting previous values) on the group
*/
public static void groupIdentify(@NotNull String type, @NotNull String key, @NotNull Object properties) {
@@ -212,7 +212,7 @@ public static void flush() {
/**
* Check if the given feature flag is enabled for the given distinct ID.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
* @return True if the feature flag is enabled for the given distinct ID, false otherwise
*/
@@ -223,9 +223,9 @@ public static boolean isFeatureEnabled(@NotNull String key, @NotNull String dist
/**
* Check if the given feature flag is enabled for the given distinct ID with extra context.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
- * @param context Extra context to pass to the feature flag evaluation
+ * @param context Extra context to pass to the feature flag evaluation
* @return True if the feature flag is enabled for the given distinct ID, false otherwise
*/
public static boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId, @Nullable FeatureFlagContext context) {
@@ -235,7 +235,7 @@ public static boolean isFeatureEnabled(@NotNull String key, @NotNull String dist
/**
* Get the feature flag state for the given distinct ID.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
* @return Feature flag state
*/
@@ -246,9 +246,9 @@ public static boolean isFeatureEnabled(@NotNull String key, @NotNull String dist
/**
* Get the feature flag state for the given distinct ID with extra context.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
- * @param context Extra context to pass to the feature flag evaluation
+ * @param context Extra context to pass to the feature flag evaluation
* @return Feature flag state
*/
public static @NotNull FeatureFlagState getFeatureFlag(@NotNull String key, @NotNull String distinctId, @Nullable FeatureFlagContext context) {
@@ -271,7 +271,7 @@ public static boolean isFeatureEnabled(@NotNull String key, @NotNull String dist
* Get all feature flags for the given distinct ID with extra context.
*
* @param distinctId Unique ID of the target in your database. May not be empty
- * @param context Extra context to pass to the feature flag evaluation
+ * @param context Extra context to pass to the feature flag evaluation
* @return Feature flag states
*/
public static @NotNull FeatureFlagStates getAllFeatureFlags(@NotNull String distinctId, @Nullable FeatureFlagContext context) {
@@ -288,6 +288,21 @@ public static void reloadFeatureFlags() {
getClient().reloadFeatureFlags();
}
+ /**
+ * Blocks until local feature flags have been fetched, or the timeout is reached.
+ *
+ * This is useful during application startup to ensure feature flags are available
+ * before accepting requests. If it fails, local evals are disabled until it re-fetches (and it succeeds).
+ *
+ * @param timeout Maximum time to wait for the fetch to complete
+ * @return true if feature flags were successfully loaded, false if the fetch failed or timed out
+ * @throws UnsupportedOperationException if local feature flag evaluation is not enabled
+ */
+ @Blocking
+ public static boolean loadRemoteFeatureFlags(@NotNull Duration timeout) {
+ return getClient().loadRemoteFeatureFlags(timeout);
+ }
+
// Exceptions
public static void captureException(@NotNull Throwable throwable) {
diff --git a/src/main/java/net/hollowcube/posthog/PostHogClient.java b/src/main/java/net/hollowcube/posthog/PostHogClient.java
index 78017eb..d23f6e0 100644
--- a/src/main/java/net/hollowcube/posthog/PostHogClient.java
+++ b/src/main/java/net/hollowcube/posthog/PostHogClient.java
@@ -49,7 +49,7 @@ public sealed interface PostHogClient permits PostHogClientImpl, PostHogClientNo
* Capture an event with the given name for the given distinct ID with no properties.
*
* @param distinctId Unique ID of the target in your database. May not be empty.
- * @param event Name of the event. May not be empty.
+ * @param event Name of the event. May not be empty.
*/
default void capture(@NotNull String distinctId, @NotNull String event) {
capture(distinctId, event, Map.of());
@@ -59,7 +59,7 @@ default void capture(@NotNull String distinctId, @NotNull String event) {
* Capture an event with the given name for the given distinct ID with the provided properties.
*
* @param distinctId Unique ID of the target in your database. May not be empty.
- * @param event Name of the event. May not be empty.
+ * @param event Name of the event. May not be empty.
* @param properties Event properties
*/
default void capture(@NotNull String distinctId, @NotNull String event, @NotNull Map properties) {
@@ -72,7 +72,7 @@ default void capture(@NotNull String distinctId, @NotNull String event, @NotNull
* The object must be serializable to a JSON object via Gson (not primitive or array)
*
* @param distinctId Unique ID of the target in your database. May not be empty.
- * @param event Name of the event. May not be empty.
+ * @param event Name of the event. May not be empty.
* @param properties Event object data
*/
void capture(@NotNull String distinctId, @NotNull String event, @NotNull Object properties);
@@ -80,8 +80,8 @@ default void capture(@NotNull String distinctId, @NotNull String event, @NotNull
/**
* Link the given properties with the person profile of the user (distinct id).
*
- * @param distinctId Unique ID of the target in your database. May not be empty.
- * @param properties Properties to set (including overwriting previous values) on the person profile
+ * @param distinctId Unique ID of the target in your database. May not be empty.
+ * @param properties Properties to set (including overwriting previous values) on the person profile
* @param propertiesSetOnce Properties to set only if missing on the person profile
*/
default void identify(@NotNull String distinctId, @Nullable Map properties, @Nullable Map propertiesSetOnce) {
@@ -96,8 +96,8 @@ default void identify(@NotNull String distinctId, @Nullable Map
*
* The objects must be serializable to a JSON object via Gson (not primitive or array)
*
- * @param distinctId Unique ID of the target in your database. May not be empty.
- * @param properties Properties to set (including overwriting previous values) on the person profile
+ * @param distinctId Unique ID of the target in your database. May not be empty.
+ * @param properties Properties to set (including overwriting previous values) on the person profile
* @param propertiesSetOnce Properties to set only if missing on the person profile
*/
default void identify(@NotNull String distinctId, @Nullable Object properties, @Nullable Object propertiesSetOnce) {
@@ -136,8 +136,8 @@ default void identify(@NotNull String distinctId, @Nullable Object properties) {
/**
* Set the given properties with the person profile of the user (distinct id).
*
- * @param distinctId Unique ID of the target in your database. May not be empty.
- * @param properties Properties to set (including overwriting previous values) on the person profile
+ * @param distinctId Unique ID of the target in your database. May not be empty.
+ * @param properties Properties to set (including overwriting previous values) on the person profile
* @param propertiesSetOnce Properties to set only if missing on the person profile
*/
default void set(@NotNull String distinctId, @Nullable Map properties, @Nullable Map propertiesSetOnce) {
@@ -152,8 +152,8 @@ default void set(@NotNull String distinctId, @Nullable Map prope
*
* The objects must be serializable to a JSON object via Gson (not primitive or array)
*
- * @param distinctId Unique ID of the target in your database. May not be empty.
- * @param properties Properties to set (including overwriting previous values) on the person profile
+ * @param distinctId Unique ID of the target in your database. May not be empty.
+ * @param properties Properties to set (including overwriting previous values) on the person profile
* @param propertiesSetOnce Properties to set only if missing on the person profile
*/
default void set(@NotNull String distinctId, @Nullable Object properties, @Nullable Object propertiesSetOnce) {
@@ -193,7 +193,7 @@ default void set(@NotNull String distinctId, @Nullable Object properties) {
* Alias the given distinct ID to the given alias.
*
* @param distinctId Unique ID of the target in your database. May not be empty.
- * @param alias Alias to set for the distinct ID. May not be empty.
+ * @param alias Alias to set for the distinct ID. May not be empty.
*/
default void alias(@NotNull String distinctId, @NotNull String alias) {
capture(distinctId, CREATE_ALIAS, Map.of(
@@ -205,8 +205,8 @@ default void alias(@NotNull String distinctId, @NotNull String alias) {
/**
* Assign the given properties to the given group (type & key).
*
- * @param type Group type. Must not be empty
- * @param key Group key. Must not be empty
+ * @param type Group type. Must not be empty
+ * @param key Group key. Must not be empty
* @param properties Properties to set (including overwriting previous values) on the group
*/
default void groupIdentify(@NotNull String type, @NotNull String key, @NotNull Map properties) {
@@ -218,8 +218,8 @@ default void groupIdentify(@NotNull String type, @NotNull String key, @NotNull M
*
* The object must be serializable to a JSON object via Gson (not primitive or array)
*
- * @param type Group type. Must not be empty
- * @param key Group key. Must not be empty
+ * @param type Group type. Must not be empty
+ * @param key Group key. Must not be empty
* @param properties Properties to set (including overwriting previous values) on the group
*/
default void groupIdentify(@NotNull String type, @NotNull String key, @NotNull Object properties) {
@@ -243,7 +243,7 @@ default void groupIdentify(@NotNull String type, @NotNull String key, @NotNull O
/**
* Check if the given feature flag is enabled for the given distinct ID.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
* @return True if the feature flag is enabled for the given distinct ID, false otherwise
*/
@@ -254,9 +254,9 @@ default boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId
/**
* Check if the given feature flag is enabled for the given distinct ID with extra context.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
- * @param context Extra context to pass to the feature flag evaluation
+ * @param context Extra context to pass to the feature flag evaluation
* @return True if the feature flag is enabled for the given distinct ID, false otherwise
*/
default boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId, @Nullable FeatureFlagContext context) {
@@ -266,7 +266,7 @@ default boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId
/**
* Get the feature flag state for the given distinct ID.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
* @return Feature flag state
*/
@@ -277,9 +277,9 @@ default boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId
/**
* Get the feature flag state for the given distinct ID with extra context.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
- * @param context Extra context to pass to the feature flag evaluation
+ * @param context Extra context to pass to the feature flag evaluation
* @return Feature flag state
*/
@NotNull FeatureFlagState getFeatureFlag(@NotNull String key, @NotNull String distinctId, @Nullable FeatureFlagContext context);
@@ -287,7 +287,7 @@ default boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId
/**
* Get the feature flag payload for the given distinct ID.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
* @return Feature flag payload, or null if the feature flag is disabled or has no payload configured.
*/
@@ -298,9 +298,9 @@ default boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId
/**
* Get the feature flag payload for the given distinct ID.
*
- * @param key Feature flag key
+ * @param key Feature flag key
* @param distinctId Unique ID of the target in your database. May not be empty
- * @param context Extra context to pass to the feature flag evaluation
+ * @param context Extra context to pass to the feature flag evaluation
* @return Feature flag payload, or null if the feature flag is disabled or has no payload configured.
*/
default @Nullable String getFeatureFlagPayload(@NotNull String key, @NotNull String distinctId, @Nullable FeatureFlagContext context) {
@@ -321,7 +321,7 @@ default boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId
* Get all feature flags for the given distinct ID with extra context.
*
* @param distinctId Unique ID of the target in your database. May not be empty
- * @param context Extra context to pass to the feature flag evaluation
+ * @param context Extra context to pass to the feature flag evaluation
* @return Feature flag states
*/
@NotNull FeatureFlagStates getAllFeatureFlags(@NotNull String distinctId, @Nullable FeatureFlagContext context);
@@ -334,6 +334,33 @@ default boolean isFeatureEnabled(@NotNull String key, @NotNull String distinctId
*/
void reloadFeatureFlags();
+ /**
+ * Blocks until local feature flags have been fetched, or the timeout is reached.
+ *
+ * This is useful during application startup to ensure feature flags are available
+ * before accepting requests. If it fails, local evals are disabled until it re-fetches (and it succeeds).
+ *
+ * Uses the timeout of {@link #loadRemoteFeatureFlags(Duration)}
+ *
+ * @return true if feature flags were successfully loaded, false if the fetch failed or timed out
+ * @throws UnsupportedOperationException if local feature flag evaluation is not enabled
+ */
+ @Blocking
+ boolean loadRemoteFeatureFlags();
+
+ /**
+ * Blocks until local feature flags have been fetched, or the timeout is reached.
+ *
+ *
This is useful during application startup to ensure feature flags are available
+ * before accepting requests. If it fails, local evals are disabled until it re-fetches (and it succeeds).
+ *
+ * @param timeout Maximum time to wait for the fetch to complete
+ * @return true if feature flags were successfully loaded, false if the fetch failed or timed out
+ * @throws UnsupportedOperationException if local feature flag evaluation is not enabled
+ */
+ @Blocking
+ boolean loadRemoteFeatureFlags(@NotNull Duration timeout);
+
// Exceptions
@@ -370,6 +397,7 @@ final class Builder {
private boolean sendFeatureFlagEvents = false;
private Duration featureFlagsPollingInterval = Duration.ofMinutes(5);
private Duration featureFlagsRequestTimeout = Duration.ofSeconds(3);
+ private Duration blockUntilLocalFlagsLoaded = null;
private BiFunction exceptionMiddleware = null;
@@ -445,6 +473,24 @@ private Builder(@NotNull String projectApiKey) {
return this;
}
+ /**
+ * Block during client construction until local feature flags have been fetched.
+ *
+ * This ensures feature flags are available immediately after the client is built.
+ * If the fetch fails or times out, construction still succeeds but feature flag
+ * evaluations will return {@link FeatureFlagState#DISABLED} until the next successful fetch.
+ *
+ * Requires a personal API key to be set via {@link #personalApiKey(String)}.
+ *
+ * @param timeout Maximum time to wait for the fetch to complete
+ * @return this builder
+ */
+ @Contract(pure = true)
+ public @NotNull Builder blockUntilLocalFlagsLoaded(@NotNull Duration timeout) {
+ this.blockUntilLocalFlagsLoaded = Objects.requireNonNull(timeout);
+ return this;
+ }
+
@Contract(pure = true)
public @NotNull Builder exceptionMiddleware(@NotNull BiFunction exceptionMiddleware) {
this.exceptionMiddleware = Objects.requireNonNull(exceptionMiddleware);
@@ -470,7 +516,7 @@ private Builder(@NotNull String projectApiKey) {
.disableJdkUnsafe()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create());
- return new PostHogClientImpl(
+ var client = new PostHogClientImpl(
gson,
endpoint, projectApiKey, personalApiKey, // API
flushInterval, maxBatchSize, defaultEventProperties, // Events
@@ -479,6 +525,10 @@ private Builder(@NotNull String projectApiKey) {
featureFlagsPollingInterval, featureFlagsRequestTimeout,
exceptionMiddleware // Exceptions
);
+ if (blockUntilLocalFlagsLoaded != null) {
+ client.loadRemoteFeatureFlags(blockUntilLocalFlagsLoaded);
+ }
+ return client;
}
}
diff --git a/src/main/java/net/hollowcube/posthog/PostHogClientImpl.java b/src/main/java/net/hollowcube/posthog/PostHogClientImpl.java
index 736e61a..111e60b 100644
--- a/src/main/java/net/hollowcube/posthog/PostHogClientImpl.java
+++ b/src/main/java/net/hollowcube/posthog/PostHogClientImpl.java
@@ -50,6 +50,7 @@ public final class PostHogClientImpl implements PostHogClient {
private Map featureFlags = null; // Null until first fetch
private final Map recentlyCapturedFeatureFlags = new ConcurrentHashMap<>();
+ private volatile boolean warnedAboutMissingFlags = false;
private final boolean allowRemoteFeatureFlagEvaluation;
private final boolean sendFeatureFlagEvents;
private final Duration featureFlagsRequestTimeout;
@@ -202,6 +203,18 @@ private void sendEventBatch(@NotNull JsonArray batch) {
}
}
+ // This occurs when local flags are not loaded and remote eval is disabled, so we return disabled
+ // If a client wants, they can block until local values are loaded with PostHogClient#loadRemoteFeatureFlags
+ if (result == null) {
+ if (!warnedAboutMissingFlags) {
+ warnedAboutMissingFlags = true;
+ log.warn("Local feature flags not yet loaded and remote evaluation is disabled. " +
+ "Returning DISABLED for all flags until loaded. " +
+ "Use loadRemoteFeatureFlags() or blockUntilLocalFlagsLoaded() to avoid this.");
+ }
+ return FeatureFlagState.DISABLED;
+ }
+
// Send feature flag called event if configured to do so.
final boolean sendCalledEvent = featureFlagContext.sendFeatureFlagEvents() != null
? featureFlagContext.sendFeatureFlagEvents()
@@ -271,18 +284,26 @@ public void reloadFeatureFlags() {
}
@Blocking
- private void loadRemoteFeatureFlags() {
- if (this.personalApiKey == null) return; // Sanity check
+ public boolean loadRemoteFeatureFlags() {
+ return this.loadRemoteFeatureFlags(featureFlagsRequestTimeout);
+ }
+
+ @Blocking
+ public boolean loadRemoteFeatureFlags(@NotNull Duration timeout) {
+ if (this.personalApiKey == null) {
+ throw new UnsupportedOperationException("Local feature flag evaluation is not enabled (no personal API key)");
+ }
final HttpRequest req = HttpRequest.newBuilder(URI.create(String.format("%s/api/feature_flag/local_evaluation", endpoint)))
.header("Authorization", String.format("Bearer %s", this.personalApiKey))
.header("User-Agent", USER_AGENT)
- .timeout(featureFlagsRequestTimeout)
+ .timeout(timeout)
.build();
try {
final HttpResponse res = this.httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
log.error("unexpected response from /api/feature_flag/local_evaluation ({}): {}", res.statusCode(), res.body());
+ return false;
}
final FeatureFlagsResponse resBody = this.gson.fromJson(res.body(), FeatureFlagsResponse.class);
@@ -291,13 +312,17 @@ private void loadRemoteFeatureFlags() {
newFeatureFlags.put(flag.key(), flag);
}
this.featureFlags = Map.copyOf(newFeatureFlags);
+ return true;
} catch (InterruptedException ignored) {
// Do nothing just exit
+ return false;
} catch (HttpTimeoutException e) {
log.warn("timed out making /api/feature_flag/local_evaluation request", e);
+ return false;
} catch (Exception e) {
// Catch everything because we do not want the timer itself to stop running.
log.error("failed to make /api/feature_flag/local_evaluation request", e);
+ return false;
}
}
diff --git a/src/main/java/net/hollowcube/posthog/PostHogClientNoop.java b/src/main/java/net/hollowcube/posthog/PostHogClientNoop.java
index 48b5e51..da06a8c 100644
--- a/src/main/java/net/hollowcube/posthog/PostHogClientNoop.java
+++ b/src/main/java/net/hollowcube/posthog/PostHogClientNoop.java
@@ -37,6 +37,16 @@ public void flush() {
public void reloadFeatureFlags() {
}
+ @Override
+ public boolean loadRemoteFeatureFlags() {
+ return true;
+ }
+
+ @Override
+ public boolean loadRemoteFeatureFlags(@NotNull Duration timeout) {
+ return true;
+ }
+
@Override
public void captureException(@NotNull Throwable throwable, @Nullable String distinctId, @Nullable Object properties) {