diff --git a/src/main/java/net/hollowcube/posthog/PostHog.java b/src/main/java/net/hollowcube/posthog/PostHog.java index dd1e5d7..94d74ff 100644 --- a/src/main/java/net/hollowcube/posthog/PostHog.java +++ b/src/main/java/net/hollowcube/posthog/PostHog.java @@ -43,7 +43,7 @@ public static void shutdown(@NotNull Duration timeout) { * 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. */ public static void capture(@NotNull String distinctId, @NotNull String event) { getClient().capture(distinctId, event); @@ -53,7 +53,7 @@ public static 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 */ public static void capture(@NotNull String distinctId, @NotNull String event, @NotNull Map properties) { @@ -66,7 +66,7 @@ public static void capture(@NotNull String distinctId, @NotNull String event, @N *

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 */ public static void capture(@NotNull String distinctId, @NotNull String event, @NotNull Object properties) { @@ -76,8 +76,8 @@ public static void capture(@NotNull String distinctId, @NotNull String event, @N /** * 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 */ public static void identify(@NotNull String distinctId, @Nullable Map properties, @Nullable Map propertiesSetOnce) { @@ -89,8 +89,8 @@ public static void identify(@NotNull String distinctId, @Nullable MapThe 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 identify(@NotNull String distinctId, @Nullable Object properties, @Nullable Object propertiesSetOnce) { @@ -122,8 +122,8 @@ public static void identify(@NotNull String distinctId, @Nullable Object propert /** * 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 */ public static void set(@NotNull String distinctId, @Nullable Map properties, @Nullable Map propertiesSetOnce) { @@ -135,8 +135,8 @@ public static void set(@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 */ 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) {