From b87af25c1cb03016d5f27429e88f13854fb9c653 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 13:15:31 -0400 Subject: [PATCH 1/8] chore!: pass the load result to the feature flags callback PostHogConfig.onFeatureFlags and reloadFeatureFlags() now take a PostHogFeatureFlagsCallback, whose loaded() receives a PostHogFeatureFlagsLoaded with the enabled flags, their values, and errorsLoading, matching posthog-ios 4.0. Every path that invoked the callback passes an accurate result, including failures, a disabled SDK, and bootstrap. PostHogOnFeatureFlags moves to posthog-server under the same name, so the server's API is unchanged and it can drop the type in its own major. --- .changeset/android-v4-major.md | 1 + .changeset/server-keep-on-feature-flags.md | 5 + .../PostHogAndroidEventSnapshotsTest.kt | 6 +- .../java/com/posthog/android/sample/MyApp.kt | 4 +- posthog-server/api/posthog-server.api | 4 + .../java/com/posthog/PostHogOnFeatureFlags.kt | 0 .../java/com/posthog/server/PostHogConfig.kt | 3 +- .../com/posthog/server/PostHogConfigTest.kt | 7 +- posthog/api/posthog.api | 41 +++--- posthog/src/main/java/com/posthog/PostHog.kt | 32 +++-- .../main/java/com/posthog/PostHogConfig.kt | 5 +- .../posthog/PostHogFeatureFlagsCallback.kt | 26 ++++ .../com/posthog/PostHogFeatureFlagsLoaded.kt | 47 +++++++ .../main/java/com/posthog/PostHogInterface.kt | 17 ++- .../posthog/internal/PostHogRemoteConfig.kt | 59 +++++--- .../java/com/posthog/PostHogBootstrapTest.kt | 11 +- .../com/posthog/PostHogFeatureFlagsTest.kt | 8 +- .../src/test/java/com/posthog/PostHogTest.kt | 5 +- .../internal/PostHogRemoteConfigTest.kt | 129 ++++++++++++------ .../java/com/posthog/PostHogFake.kt | 2 +- 20 files changed, 299 insertions(+), 113 deletions(-) create mode 100644 .changeset/server-keep-on-feature-flags.md rename {posthog => posthog-server}/src/main/java/com/posthog/PostHogOnFeatureFlags.kt (100%) create mode 100644 posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt create mode 100644 posthog/src/main/java/com/posthog/PostHogFeatureFlagsLoaded.kt diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index e89b373a7..fc2077550 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -11,3 +11,4 @@ - **Breaking:** return `null` instead of the raw string for a malformed, empty or whitespace-only feature flag payload from `getFeatureFlagResult(key)?.payload`; payloads must be valid JSON (a JSON `null` payload now also reads as `null` instead of the string `"null"`) - **Breaking:** make SDK-internal helpers in the `com.posthog.internal` and `com.posthog.android.replay.internal` packages non-public - **Breaking:** remove the deprecated `remoteConfig` config option; the SDK now always loads remote config at setup +- **Breaking:** replace `PostHogOnFeatureFlags` with `PostHogFeatureFlagsCallback` in `PostHogConfig.onFeatureFlags` and `reloadFeatureFlags()`; it receives a `PostHogFeatureFlagsLoaded` with the enabled flags, their values, and `errorsLoading` diff --git a/.changeset/server-keep-on-feature-flags.md b/.changeset/server-keep-on-feature-flags.md new file mode 100644 index 000000000..611a00d16 --- /dev/null +++ b/.changeset/server-keep-on-feature-flags.md @@ -0,0 +1,5 @@ +--- +'posthog-server': patch +--- + +Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt index ae466718a..a29635090 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt @@ -3,8 +3,8 @@ package com.posthog.android import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.gson.GsonBuilder +import com.posthog.PostHogFeatureFlagsCallback import com.posthog.PostHogInterface -import com.posthog.PostHogOnFeatureFlags import com.posthog.internal.PostHogContext import com.posthog.internal.PostHogDateProvider import com.posthog.internal.PostHogDeviceDateProvider @@ -67,7 +67,7 @@ internal class PostHogAndroidEventSnapshotsTest { fun `batch request and enriched event shapes match snapshot`() { val fixture = createFixture() val flagsLoaded = CountDownLatch(1) - fixture.config.onFeatureFlags = PostHogOnFeatureFlags { flagsLoaded.countDown() } + fixture.config.onFeatureFlags = PostHogFeatureFlagsCallback { flagsLoaded.countDown() } val sut = fixture.client sut.capture( @@ -115,7 +115,7 @@ internal class PostHogAndroidEventSnapshotsTest { mapOf("industry" to "analytics"), reloadFeatureFlags = false, ) - fixture.client.reloadFeatureFlags(PostHogOnFeatureFlags { loaded.countDown() }) + fixture.client.reloadFeatureFlags(PostHogFeatureFlagsCallback { loaded.countDown() }) assertTrue(loaded.await(10, TimeUnit.SECONDS), "Feature flags request did not finish") val request = fixture.server.takeRequests(CONFIG_PATH, "/flags/?v=2").single { it.path == "/flags/?v=2" } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt index 5b328a9ce..bfdb0e45e 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt @@ -2,7 +2,7 @@ package com.posthog.android.sample import android.app.Application import android.os.StrictMode -import com.posthog.PostHogOnFeatureFlags +import com.posthog.PostHogFeatureFlagsCallback import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig @@ -39,7 +39,7 @@ class MyApp : Application() { sessionReplay = true preloadFeatureFlags = true sendFeatureFlagEvent = false - onFeatureFlags = PostHogOnFeatureFlags { print("feature flags loaded") } + onFeatureFlags = PostHogFeatureFlagsCallback { print("feature flags loaded") } addBeforeSend { event -> if (event.event == "test_event") { null diff --git a/posthog-server/api/posthog-server.api b/posthog-server/api/posthog-server.api index 51421e12d..52a3c305a 100644 --- a/posthog-server/api/posthog-server.api +++ b/posthog-server/api/posthog-server.api @@ -1,3 +1,7 @@ +public abstract interface class com/posthog/PostHogOnFeatureFlags { + public abstract fun loaded ()V +} + public final class com/posthog/server/PostHog : com/posthog/PostHogStateless, com/posthog/server/PostHogInterface { public static final field Companion Lcom/posthog/server/PostHog$Companion; public fun ()V diff --git a/posthog/src/main/java/com/posthog/PostHogOnFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/PostHogOnFeatureFlags.kt similarity index 100% rename from posthog/src/main/java/com/posthog/PostHogOnFeatureFlags.kt rename to posthog-server/src/main/java/com/posthog/PostHogOnFeatureFlags.kt diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt index e0adad853..1423c65bc 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -4,6 +4,7 @@ import com.posthog.BuildConfig import com.posthog.PostHogBeforeSend import com.posthog.PostHogEncryption import com.posthog.PostHogExperimental +import com.posthog.PostHogFeatureFlagsCallback import com.posthog.PostHogIntegration import com.posthog.PostHogOnFeatureFlags import com.posthog.server.internal.PostHogFeatureFlags @@ -311,7 +312,7 @@ public open class PostHogConfig constructor( maxBatchSize = maxBatchSize, flushIntervalSeconds = flushIntervalSeconds, encryption = encryption, - onFeatureFlags = onFeatureFlags, + onFeatureFlags = onFeatureFlags?.let { callback -> PostHogFeatureFlagsCallback { callback.loaded() } }, proxy = proxy, remoteConfigProvider = { config, api, _, _, _, _ -> PostHogFeatureFlags( diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt index b68787d37..f04aade97 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt @@ -1,5 +1,6 @@ package com.posthog.server +import com.posthog.PostHogFeatureFlagsLoaded import com.posthog.PostHogOnFeatureFlags import java.net.Proxy import kotlin.test.Test @@ -148,7 +149,8 @@ internal class PostHogConfigTest { @Test fun `asCoreConfig creates core config with all properties`() { val mockEncryption = createMockEncryption() - val mockOnFeatureFlags = PostHogOnFeatureFlags { } + var onFeatureFlagsCalls = 0 + val mockOnFeatureFlags = PostHogOnFeatureFlags { onFeatureFlagsCalls++ } val config = PostHogConfig( @@ -180,7 +182,8 @@ internal class PostHogConfigTest { assertEquals(30, coreConfig.maxBatchSize) assertEquals(45, coreConfig.flushIntervalSeconds) assertEquals(mockEncryption, coreConfig.encryption) - assertEquals(mockOnFeatureFlags, coreConfig.onFeatureFlags) + coreConfig.onFeatureFlags!!.loaded(PostHogFeatureFlagsLoaded(emptyMap(), errorsLoading = false)) + assertEquals(1, onFeatureFlagsCalls) } @Test diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index d515cea20..c949a9dbd 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -52,7 +52,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun optOut ()V public fun register (Ljava/lang/String;Ljava/lang/Object;)V public fun registerPushNotificationToken (Ljava/lang/String;Ljava/lang/String;)V - public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V + public fun reloadFeatureFlags (Lcom/posthog/PostHogFeatureFlagsCallback;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public fun resetPersonPropertiesForFlags (Z)V @@ -101,7 +101,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public final fun overrideSharedInstance (Lcom/posthog/PostHogInterface;)V public fun register (Ljava/lang/String;Ljava/lang/Object;)V public fun registerPushNotificationToken (Ljava/lang/String;Ljava/lang/String;)V - public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V + public fun reloadFeatureFlags (Lcom/posthog/PostHogFeatureFlagsCallback;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public fun resetPersonPropertiesForFlags (Z)V @@ -156,8 +156,8 @@ public class com/posthog/PostHogConfig { public static final field DEFAULT_MAX_QUEUE_SIZE I public static final field DEFAULT_US_ASSETS_HOST Ljava/lang/String; public static final field DEFAULT_US_HOST Ljava/lang/String; - public fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogFeatureFlagsCallback;ZLkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogFeatureFlagsCallback;ZLkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V public final fun getBeforeSendList ()Ljava/util/List; @@ -186,7 +186,7 @@ public class com/posthog/PostHogConfig { public final fun getMaxQueueSize ()I public final fun getMaxRetries ()I public final fun getNetworkStatus ()Lcom/posthog/internal/PostHogNetworkStatus; - public final fun getOnFeatureFlags ()Lcom/posthog/PostHogOnFeatureFlags; + public final fun getOnFeatureFlags ()Lcom/posthog/PostHogFeatureFlagsCallback; public final fun getOptOut ()Z public final fun getPersistOptOut ()Z public final fun getPersonProfiles ()Lcom/posthog/PersonProfiles; @@ -237,7 +237,7 @@ public class com/posthog/PostHogConfig { public final fun setMaxQueueSize (I)V public final fun setMaxRetries (I)V public final fun setNetworkStatus (Lcom/posthog/internal/PostHogNetworkStatus;)V - public final fun setOnFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V + public final fun setOnFeatureFlags (Lcom/posthog/PostHogFeatureFlagsCallback;)V public final fun setOptOut (Z)V public final fun setPersistOptOut (Z)V public final fun setPersonProfiles (Lcom/posthog/PersonProfiles;)V @@ -344,6 +344,17 @@ public final class com/posthog/PostHogEventName$Companion { public abstract interface annotation class com/posthog/PostHogExperimental : java/lang/annotation/Annotation { } +public abstract interface class com/posthog/PostHogFeatureFlagsCallback { + public abstract fun loaded (Lcom/posthog/PostHogFeatureFlagsLoaded;)V +} + +public final class com/posthog/PostHogFeatureFlagsLoaded { + public fun (Ljava/util/Map;Z)V + public final fun getErrorsLoading ()Z + public final fun getFlags ()Ljava/util/List; + public final fun getVariants ()Ljava/util/Map; +} + public abstract interface class com/posthog/PostHogIntegration { public fun install (Lcom/posthog/PostHogInterface;)V public fun onChange ()V @@ -396,8 +407,8 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun isSessionReplayActive ()Z public abstract fun register (Ljava/lang/String;Ljava/lang/Object;)V public abstract fun registerPushNotificationToken (Ljava/lang/String;Ljava/lang/String;)V - public abstract fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V - public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V + public abstract fun reloadFeatureFlags (Lcom/posthog/PostHogFeatureFlagsCallback;)V + public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogFeatureFlagsCallback;ILjava/lang/Object;)V public abstract fun reset ()V public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V @@ -431,7 +442,7 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun isFeatureEnabled$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZLjava/lang/Boolean;ILjava/lang/Object;)Z - public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V + public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogFeatureFlagsCallback;ILjava/lang/Object;)V public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V public static synthetic fun screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V @@ -453,10 +464,6 @@ public final class com/posthog/PostHogOkHttpInterceptor : okhttp3/Interceptor { public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; } -public abstract interface class com/posthog/PostHogOnFeatureFlags { - public abstract fun loaded ()V -} - public abstract interface class com/posthog/PostHogOptInReceiver { public abstract fun onOptIn ()V } @@ -1042,10 +1049,10 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern public fun isMinimalFlagCalledEventsEnabled ()Z public final fun isNativeCrashCaptureEnabled ()Z public final fun isSessionReplayFlagActive ()Z - public final fun loadFeatureFlags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V - public static synthetic fun loadFeatureFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V - public final fun loadRemoteConfig (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V - public static synthetic fun loadRemoteConfig$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V + public final fun loadFeatureFlags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function0;Lcom/posthog/PostHogFeatureFlagsCallback;)V + public static synthetic fun loadFeatureFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function0;Lcom/posthog/PostHogFeatureFlagsCallback;ILjava/lang/Object;)V + public final fun loadRemoteConfig (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function0;Lcom/posthog/PostHogFeatureFlagsCallback;)V + public static synthetic fun loadRemoteConfig$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function0;Lcom/posthog/PostHogFeatureFlagsCallback;ILjava/lang/Object;)V public final fun makeSamplingDecision (Ljava/lang/String;)Z public final fun persistPushConfigCache ()V public final fun resetGroupPropertiesForFlags (Ljava/lang/String;)V diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index a616e878c..8c636fd1e 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -188,8 +188,8 @@ public class PostHog private constructor( private var isPersonProcessingLoaded: Boolean = false // this is called if the feature flags are loaded for the first time and recording isn't started yet - private val internalOnFeatureFlagsLoaded = - PostHogOnFeatureFlags { + private val internalOnFeatureFlagsLoaded: () -> Unit = + { // under setupLock so a concurrent optOut can't uninstall replay between the check and the start synchronized(setupLock) { sessionReplayHandler?.let { @@ -397,8 +397,8 @@ public class PostHog private constructor( // Bootstrap already seeded the flags in RemoteConfig's init; fire the flags-loaded // callbacks now so listeners aren't blocked on the first network response. if (remoteConfig?.hasBootstrapFlags() == true) { - notifyFeatureFlagsCallback(internalOnFeatureFlagsLoaded) - notifyFeatureFlagsCallback(config.onFeatureFlags) + internalOnFeatureFlagsLoaded() + notifyFeatureFlagsCallback(config.onFeatureFlags, remoteConfig?.getFeatureFlags(), errorsLoading = false) } loadRemoteConfigRequest( internalOnFeatureFlagsLoaded, @@ -1915,18 +1915,22 @@ public class PostHog private constructor( } // Invokes the feature flags callback, swallowing exceptions like runOnFeatureFlagsCallbacks. - private fun notifyFeatureFlagsCallback(onFeatureFlags: PostHogOnFeatureFlags?) { + private fun notifyFeatureFlagsCallback( + onFeatureFlags: PostHogFeatureFlagsCallback?, + featureFlags: Map?, + errorsLoading: Boolean, + ) { try { - onFeatureFlags?.loaded() + onFeatureFlags?.loaded(PostHogFeatureFlagsLoaded(featureFlags ?: emptyMap(), errorsLoading)) } catch (e: Throwable) { config?.logger?.log("Executing the feature flags callback failed: $e") } } - public override fun reloadFeatureFlags(onFeatureFlags: PostHogOnFeatureFlags?) { + public override fun reloadFeatureFlags(onFeatureFlags: PostHogFeatureFlagsCallback?) { if (!isEnabled()) { // Still invoke the callback so awaiting callers aren't left hanging. - notifyFeatureFlagsCallback(onFeatureFlags) + notifyFeatureFlagsCallback(onFeatureFlags, featureFlags = null, errorsLoading = true) return } loadFeatureFlagsRequest( @@ -1936,8 +1940,8 @@ public class PostHog private constructor( } private fun loadFeatureFlagsRequest( - internalOnFeatureFlags: PostHogOnFeatureFlags, - onFeatureFlags: PostHogOnFeatureFlags? = null, + internalOnFeatureFlags: () -> Unit, + onFeatureFlags: PostHogFeatureFlagsCallback? = null, ) { @Suppress("UNCHECKED_CAST") val groups = getPreferences().getValue(GROUPS) as? Map @@ -1952,7 +1956,7 @@ public class PostHog private constructor( if (distinctId.isBlank()) { config?.logger?.log("Feature flags not loaded, distinctId is invalid: $distinctId") // Still invoke the callback so awaiting callers aren't left hanging. - notifyFeatureFlagsCallback(onFeatureFlags) + notifyFeatureFlagsCallback(onFeatureFlags, remoteConfig?.getFeatureFlags(), errorsLoading = true) return } @@ -1966,8 +1970,8 @@ public class PostHog private constructor( } private fun loadRemoteConfigRequest( - internalOnFeatureFlags: PostHogOnFeatureFlags?, - onFeatureFlags: PostHogOnFeatureFlags?, + internalOnFeatureFlags: (() -> Unit)?, + onFeatureFlags: PostHogFeatureFlagsCallback?, ) { @Suppress("UNCHECKED_CAST") val groups = getPreferences().getValue(GROUPS) as? Map @@ -2705,7 +2709,7 @@ public class PostHog private constructor( ) } - public override fun reloadFeatureFlags(onFeatureFlags: PostHogOnFeatureFlags?) { + public override fun reloadFeatureFlags(onFeatureFlags: PostHogFeatureFlagsCallback?) { shared.reloadFeatureFlags(onFeatureFlags) } diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index c72ffa1e4..ee45ad3c4 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -154,10 +154,11 @@ public open class PostHogConfig( */ public var encryption: PostHogEncryption? = null, /** - * Hook that is called when feature flags are loaded + * Hook that is called when feature flags are loaded, or loading them failed + * It receives a [PostHogFeatureFlagsLoaded]; see [PostHogFeatureFlagsCallback] for the thread it runs on * Defaults to no callback */ - public var onFeatureFlags: PostHogOnFeatureFlags? = null, + public var onFeatureFlags: PostHogFeatureFlagsCallback? = null, /** * Enable Recording of Session Replays for Android * Requires Record user sessions to be enabled in the PostHog Project Settings diff --git a/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt new file mode 100644 index 000000000..a408dca37 --- /dev/null +++ b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt @@ -0,0 +1,26 @@ +package com.posthog + +/** + * Callback invoked when a feature flags load finishes, whether it succeeded or failed. + * + * It's used by `PostHogConfig.onFeatureFlags` and `reloadFeatureFlags`. It runs on a background + * thread, or right away on the calling thread when no load is attempted (for example, the SDK is + * disabled). Hop to the main thread before touching UI. + * + * Example: + * ```kotlin + * config.onFeatureFlags = PostHogFeatureFlagsCallback { loaded -> + * if (!loaded.errorsLoading && "new-checkout" in loaded.flags) { + * // The flag is enabled + * } + * } + * ``` + */ +public fun interface PostHogFeatureFlagsCallback { + /** + * Called when feature flags are loaded, or loading them failed. + * + * @param result the enabled flags, their values, and whether loading failed + */ + public fun loaded(result: PostHogFeatureFlagsLoaded) +} diff --git a/posthog/src/main/java/com/posthog/PostHogFeatureFlagsLoaded.kt b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsLoaded.kt new file mode 100644 index 000000000..095744361 --- /dev/null +++ b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsLoaded.kt @@ -0,0 +1,47 @@ +package com.posthog + +/** + * The feature flags passed to a [PostHogFeatureFlagsCallback] callback. + * + * It holds the enabled flags as they were when the callback was invoked, and whether the latest + * attempt to load them failed. Disabled flags are left out. + * + * Example: + * ```kotlin + * PostHog.reloadFeatureFlags { loaded -> + * if (loaded.errorsLoading) { + * // The values are the last known (cached) flags + * } + * if (loaded.variants["new-checkout"] == "test") { + * // Show the test variant + * } + * } + * ``` + * + * @param featureFlags the flag values, keyed by flag key; disabled flags and non-flag values are dropped + * @property errorsLoading `true` if the latest attempt to load feature flags failed. When it did, + * [flags] and [variants] hold the last known values, which can be empty. It's also `true`, with no + * values, when the SDK isn't set up or is disabled and `reloadFeatureFlags` is called. + */ +public class PostHogFeatureFlagsLoaded + @PostHogInternal + public constructor( + featureFlags: Map, + public val errorsLoading: Boolean, + ) { + /** + * The values of the enabled feature flags, keyed by flag key. + * + * A value is `true` for an enabled boolean flag, or the variant `String` for a multivariate flag. + * Disabled flags are not included. + */ + public val variants: Map = + featureFlags.entries + .mapNotNull { (key, value) -> if (value == true || value is String) key to value else null } + .toMap() + + /** + * The keys of the enabled feature flags. + */ + public val flags: List = variants.keys.toList() + } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 50e76b49b..e56fbf98b 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -124,9 +124,22 @@ public interface PostHogInterface : PostHogCoreInterface { /** * Reloads the feature flags - * @param onFeatureFlags the callback to get notified once feature flags is ready to use + * + * The callback is invoked when the reload finishes, on a background thread. If the request fails, + * [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the values are the last known flags. If + * the SDK isn't set up, it's invoked right away with no flags and `errorsLoading` set to `true`. + * + * ```kotlin + * PostHog.reloadFeatureFlags { loaded -> + * if (loaded.errorsLoading) { + * // The reload failed. loaded.variants holds the last known flags. + * } + * } + * ``` + * + * @param onFeatureFlags the callback to get notified once the reload finishes, defaults to none */ - public fun reloadFeatureFlags(onFeatureFlags: PostHogOnFeatureFlags? = null) + public fun reloadFeatureFlags(onFeatureFlags: PostHogFeatureFlagsCallback? = null) /** * Returns if a feature flag is enabled, the feature flag must be a Boolean diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index af4d05fc0..8fb661254 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -5,8 +5,9 @@ import com.google.gson.stream.JsonToken import com.google.gson.stream.MalformedJsonException import com.posthog.FeatureFlagResult import com.posthog.PostHogConfig +import com.posthog.PostHogFeatureFlagsCallback +import com.posthog.PostHogFeatureFlagsLoaded import com.posthog.PostHogInternal -import com.posthog.PostHogOnFeatureFlags import com.posthog.internal.PostHogPreferences.Companion.CAPTURE_PERFORMANCE import com.posthog.internal.PostHogPreferences.Companion.DEVICE_ID import com.posthog.internal.PostHogPreferences.Companion.ERROR_TRACKING @@ -63,8 +64,8 @@ public class PostHogRemoteConfig( val distinctId: String, val anonymousId: String?, val groups: Map?, - val internalOnFeatureFlags: List, - val onFeatureFlags: List, + val internalOnFeatureFlags: List<() -> Unit>, + val onFeatureFlags: List, ) private var pendingFeatureFlagsRequest: PendingFeatureFlagsRequest? = null @@ -251,16 +252,26 @@ public class PostHogRemoteConfig( return recordingActive } - private fun List.asSingleCallback(): PostHogOnFeatureFlags? = + @JvmName("asSingleInternalCallback") + private fun List<() -> Unit>.asSingleCallback(): (() -> Unit)? = when (size) { 0 -> null 1 -> first() - else -> PostHogOnFeatureFlags { forEach { it.runSafely() } } + else -> { + { forEach { runSafely(it) } } + } + } + + private fun List.asSingleCallback(): PostHogFeatureFlagsCallback? = + when (size) { + 0 -> null + 1 -> first() + else -> PostHogFeatureFlagsCallback { result -> forEach { runSafely { it.loaded(result) } } } } - private fun PostHogOnFeatureFlags.runSafely() { + private fun runSafely(callback: () -> Unit) { try { - loaded() + callback() } catch (e: Throwable) { try { config.logger.log("Executing the feature flags callback failed: $e") @@ -271,13 +282,16 @@ public class PostHogRemoteConfig( } private fun runOnFeatureFlagsCallbacks( - internalOnFeatureFlags: PostHogOnFeatureFlags?, - onFeatureFlags: PostHogOnFeatureFlags?, + internalOnFeatureFlags: (() -> Unit)?, + onFeatureFlags: PostHogFeatureFlagsCallback?, + errorsLoading: Boolean, ) { // if we don't load the feature flags (because there are none), we need to call the callback // because the app might be waiting for it. - internalOnFeatureFlags?.runSafely() - onFeatureFlags?.runSafely() + internalOnFeatureFlags?.let { runSafely(it) } + onFeatureFlags?.let { callback -> + runSafely { callback.loaded(PostHogFeatureFlagsLoaded(getFeatureFlags() ?: emptyMap(), errorsLoading)) } + } } // Notifies that a remote config resolution attempt finished. Callers set @@ -295,8 +309,8 @@ public class PostHogRemoteConfig( distinctId: String, anonymousId: String?, groups: Map?, - internalOnFeatureFlags: PostHogOnFeatureFlags? = null, - onFeatureFlags: PostHogOnFeatureFlags? = null, + internalOnFeatureFlags: (() -> Unit)? = null, + onFeatureFlags: PostHogFeatureFlagsCallback? = null, ) { executor.executeSafely { if (config.networkStatus?.isConnected() == false) { @@ -304,6 +318,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = true, ) if (!remoteConfigHasFetched.get()) { notifyRemoteConfigResolved() @@ -382,6 +397,8 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + // the project has flags, but the distinctId was invalid so none were loaded + errorsLoading = it.hasFeatureFlags == true, ) } @@ -392,6 +409,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = true, ) if (!remoteConfigHasFetched.get()) { notifyRemoteConfigResolved() @@ -405,6 +423,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = true, ) config.logger.log("Loading remote config failed: $e") if (!remoteConfigHasFetched.get()) { @@ -831,8 +850,8 @@ public class PostHogRemoteConfig( distinctId: String, anonymousId: String?, groups: Map?, - internalOnFeatureFlags: PostHogOnFeatureFlags?, - onFeatureFlags: PostHogOnFeatureFlags?, + internalOnFeatureFlags: (() -> Unit)?, + onFeatureFlags: PostHogFeatureFlagsCallback?, notifyRemoteConfigLoaded: Boolean = true, ) { if (config.networkStatus?.isConnected() == false) { @@ -840,6 +859,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = true, ) if (notifyRemoteConfigLoaded && !remoteConfigHasFetched.get()) { notifyRemoteConfigResolved() @@ -879,6 +899,7 @@ public class PostHogRemoteConfig( return } + var errorsLoading = true try { val deviceId = config.cachePreferences?.getValue(DEVICE_ID) as? String @@ -920,6 +941,8 @@ public class PostHogRemoteConfig( // remote config for the current identity — mark fetched + notify (outside the // lock) so the replay buffer-and-decide gate disarms instead of staying stuck. quotaLimited = true + // like posthog-ios, a quota-limited response counts as loaded with the cached flags + errorsLoading = false return@let } @@ -1029,6 +1052,7 @@ public class PostHogRemoteConfig( } isFeatureFlagsLoaded = true setFlagsLoadedFromRemote() + errorsLoading = false if (notifyRemoteConfigLoaded) { // The flags path also resolves the remote config for the current identity (it's @@ -1058,6 +1082,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = errorsLoading, ) } catch (ignored: Throwable) { // never skip the release below: leaving the claim set stops all future flag loads @@ -1088,8 +1113,8 @@ public class PostHogRemoteConfig( distinctId: String, anonymousId: String?, groups: Map?, - internalOnFeatureFlags: PostHogOnFeatureFlags? = null, - onFeatureFlags: PostHogOnFeatureFlags? = null, + internalOnFeatureFlags: (() -> Unit)? = null, + onFeatureFlags: PostHogFeatureFlagsCallback? = null, ) { executor.executeSafely { executeFeatureFlags( diff --git a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt index 214256e76..19903d96a 100644 --- a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt @@ -42,7 +42,7 @@ internal class PostHogBootstrapTest { storagePrefix: String = tmpDir.newFolder().absolutePath, logger: TestLogger? = null, optOut: Boolean = false, - onFeatureFlags: PostHogOnFeatureFlags? = null, + onFeatureFlags: PostHogFeatureFlagsCallback? = null, ): PostHogInterface { config = PostHogConfig(PROJECT_TOKEN, host).apply { @@ -365,17 +365,18 @@ internal class PostHogBootstrapTest { @Test fun `bootstrap flags fire the flags-loaded callback immediately`() { - var loaded = false + var loaded: PostHogFeatureFlagsLoaded? = null val sut = getSut( host = "http://127.0.0.1:1", // the callback fires from setup's flag loading, before /config answers reloadFeatureFlags = true, - bootstrap = PostHogBootstrapConfig(featureFlags = mapOf("beta-ui" to true)), - onFeatureFlags = PostHogOnFeatureFlags { loaded = true }, + bootstrap = PostHogBootstrapConfig(featureFlags = mapOf("beta-ui" to true, "checkout" to "test")), + onFeatureFlags = PostHogFeatureFlagsCallback { loaded = it }, ) - assertTrue(loaded) + assertEquals(mapOf("beta-ui" to true, "checkout" to "test"), loaded!!.variants) + assertFalse(loaded!!.errorsLoading) assertEquals(true, sut.getFeatureFlag("beta-ui", sendFeatureFlagEvent = false)) sut.close() diff --git a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt index 15122e4fe..399c7ee79 100644 --- a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt @@ -392,11 +392,13 @@ internal class PostHogFeatureFlagsTest { val sut = getSut(url.toString(), preloadFeatureFlags = false) sut.close() - var called = false - sut.reloadFeatureFlags { called = true } + var result: PostHogFeatureFlagsLoaded? = null + sut.reloadFeatureFlags { result = it } // isEnabled() is false after close(), so reloadFeatureFlags early-returns. The // callback must still fire (synchronously) rather than leave awaiting callers hanging. - assertTrue(called) + assertTrue(result!!.errorsLoading) + assertTrue(result!!.flags.isEmpty()) + assertTrue(result!!.variants.isEmpty()) } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 22176defb..0288881f1 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -1498,10 +1498,11 @@ internal class PostHogTest { val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) config.sessionReplay = true + @Suppress("UNCHECKED_CAST") val onFlagsLoaded = PostHog::class.java.getDeclaredField("internalOnFeatureFlagsLoaded") .apply { isAccessible = true } - .get(sut) as PostHogOnFeatureFlags + .get(sut) as () -> Unit // optOut runs while the flags-loaded callback is between reading the handler and starting it var optOut: Thread? = null @@ -1515,7 +1516,7 @@ internal class PostHogTest { } } } - onFlagsLoaded.loaded() + onFlagsLoaded() optOut!!.join() assertTrue(sut.isOptOut()) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 0d9249fc1..95914d11a 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -3,7 +3,8 @@ package com.posthog.internal import com.posthog.PROJECT_TOKEN import com.posthog.PostHogBootstrapConfig import com.posthog.PostHogConfig -import com.posthog.PostHogOnFeatureFlags +import com.posthog.PostHogFeatureFlagsCallback +import com.posthog.PostHogFeatureFlagsLoaded import com.posthog.TestHttpServers import com.posthog.internal.PostHogPreferences.Companion.CAPTURE_PERFORMANCE import com.posthog.internal.PostHogPreferences.Companion.ERROR_TRACKING @@ -20,6 +21,7 @@ import org.junit.Rule import java.io.File import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.test.AfterTest @@ -197,7 +199,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { firstLatch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { firstLatch.countDown() }, ) assertTrue(firstLatch.await(5, TimeUnit.SECONDS), "first flags load should complete") assertTrue(sut.isSessionReplayFlagActive()) @@ -211,7 +213,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { secondLatch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { secondLatch.countDown() }, ) assertTrue(secondLatch.await(5, TimeUnit.SECONDS), "second flags load should complete") @@ -235,7 +237,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -332,7 +334,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { firstLatch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { firstLatch.countDown() }, ) assertTrue(firstLatch.await(5, TimeUnit.SECONDS), "first flags load should complete") assertTrue(sut.hasRemoteConfigFetched()) @@ -346,7 +348,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { secondLatch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { secondLatch.countDown() }, ) assertTrue(secondLatch.await(5, TimeUnit.SECONDS), "second (offline) flags load should complete") @@ -397,7 +399,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -430,7 +432,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -463,7 +465,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -488,7 +490,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -602,7 +604,7 @@ internal class PostHogRemoteConfigTest { "user-in-rollout", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { firstLatch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { firstLatch.countDown() }, ) assertTrue(firstLatch.await(5, TimeUnit.SECONDS), "first flags load should complete") assertTrue(sut.isSessionReplayFlagActive()) @@ -614,7 +616,7 @@ internal class PostHogRemoteConfigTest { "user-outside-rollout", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { secondLatch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { secondLatch.countDown() }, ) assertTrue(secondLatch.await(5, TimeUnit.SECONDS), "second flags load should complete") @@ -747,7 +749,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -824,7 +826,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "quota limited remote config load should complete") @@ -917,15 +919,14 @@ internal class PostHogRemoteConfigTest { var called = false var calledInternal = false - val onFeatureFlags: PostHogOnFeatureFlags = - PostHogOnFeatureFlags { + val onFeatureFlags: PostHogFeatureFlagsCallback = + PostHogFeatureFlagsCallback { called = true } - val internalOnFeatureFlags: PostHogOnFeatureFlags = - PostHogOnFeatureFlags { - calledInternal = true - } + val internalOnFeatureFlags = { + calledInternal = true + } val sut = getSut(host = url.toString()) @@ -1101,12 +1102,12 @@ internal class PostHogRemoteConfigTest { val secondCallbackLatch = CountDownLatch(1) val firstCallback = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { firstCallbackLatch.countDown() } val secondCallback = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { secondCallbackLatch.countDown() } @@ -1180,7 +1181,7 @@ internal class PostHogRemoteConfigTest { anonymousId = "anon_id_for_hash_key", groups = mapOf("company" to "posthog"), onFeatureFlags = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { secondCallbackLatch.countDown() }, ) @@ -1730,7 +1731,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -1764,7 +1765,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -2554,7 +2555,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -2583,7 +2584,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -2620,7 +2621,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -2674,7 +2675,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -2705,7 +2706,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "remote config load should complete") @@ -2757,7 +2758,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -2786,7 +2787,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -2829,7 +2830,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") @@ -2867,7 +2868,7 @@ internal class PostHogRemoteConfigTest { "user-1", anonymousId = "anon-1", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { firstLatch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { firstLatch.countDown() }, ) assertTrue(firstLatch.await(5, TimeUnit.SECONDS), "first flags load should complete") // a complete /flags response replaces the served flags, so the bootstrapped-only key is dropped @@ -2884,7 +2885,7 @@ internal class PostHogRemoteConfigTest { "user-2", anonymousId = "anon-2", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { secondLatch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { secondLatch.countDown() }, ) assertTrue(secondLatch.await(5, TimeUnit.SECONDS), "second flags load should complete") @@ -2950,7 +2951,7 @@ internal class PostHogRemoteConfigTest { anonymousId = "anonId", emptyMap(), onFeatureFlags = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { counts[index].incrementAndGet() fired.countDown() }, @@ -2985,7 +2986,7 @@ internal class PostHogRemoteConfigTest { val invocations = AtomicInteger(0) val completed = CountDownLatch(2) val shared = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { invocations.incrementAndGet() completed.countDown() } @@ -3042,7 +3043,7 @@ internal class PostHogRemoteConfigTest { anonymousId = "anonId", emptyMap(), onFeatureFlags = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { first.countDown() throw IllegalStateException("callback blew up") }, @@ -3055,7 +3056,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { second.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { second.countDown() }, ) assertTrue(second.await(5, TimeUnit.SECONDS), "a later reload never ran - the in-flight claim was stranded") @@ -3065,6 +3066,50 @@ internal class PostHogRemoteConfigTest { http.shutdown() } + @Test + fun `the feature flags callback receives the enabled flags and their values`() { + val flags = File("src/test/resources/json/flags-v1/basic-flags-with-non-active-flags.json").readText() + val http = mockHttp(response = MockResponse().setBody(flags)) + val sut = getSut(host = http.url("/").toString()) + + val results = LinkedBlockingQueue() + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap(), onFeatureFlags = { results.add(it) }) + + val loaded = results.poll(5, TimeUnit.SECONDS)!! + assertEquals(mapOf("4535-funnel-bar-viz" to true, "splashScreenName" to "SplashV2"), loaded.variants) + assertEquals(setOf("4535-funnel-bar-viz", "splashScreenName"), loaded.flags.toSet()) + assertFalse(loaded.errorsLoading) + } + + @Test + fun `a failed flags load reports errorsLoading with the last known flags`() { + val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) + http.enqueue(MockResponse().setResponseCode(500)) + val sut = getSut(host = http.url("/").toString()) + + val results = LinkedBlockingQueue() + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap(), onFeatureFlags = { results.add(it) }) + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap(), onFeatureFlags = { results.add(it) }) + + assertFalse(results.poll(5, TimeUnit.SECONDS)!!.errorsLoading) + val failed = results.poll(5, TimeUnit.SECONDS)!! + assertTrue(failed.errorsLoading) + assertEquals(mapOf("4535-funnel-bar-viz" to true), failed.variants) + } + + @Test + fun `a failed remote config load reports errorsLoading`() { + val http = mockHttp(response = MockResponse().setResponseCode(500)) + val sut = getSut(host = http.url("/").toString()) + + val results = LinkedBlockingQueue() + sut.loadRemoteConfig("my_identify", anonymousId = "anonId", emptyMap(), onFeatureFlags = { results.add(it) }) + + val loaded = results.poll(5, TimeUnit.SECONDS)!! + assertTrue(loaded.errorsLoading) + assertTrue(loaded.flags.isEmpty()) + } + @Test fun `a throwing internal callback does not suppress the caller's callback`() { val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) @@ -3075,8 +3120,8 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - internalOnFeatureFlags = PostHogOnFeatureFlags { throw IllegalStateException("internal blew up") }, - onFeatureFlags = PostHogOnFeatureFlags { userCallbackRan.countDown() }, + internalOnFeatureFlags = { throw IllegalStateException("internal blew up") }, + onFeatureFlags = PostHogFeatureFlagsCallback { userCallbackRan.countDown() }, ) assertTrue(userCallbackRan.await(5, TimeUnit.SECONDS), "the caller's callback was suppressed by the internal one") @@ -3114,7 +3159,7 @@ internal class PostHogRemoteConfigTest { "my_identify", anonymousId = "anonId", emptyMap(), - onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + onFeatureFlags = PostHogFeatureFlagsCallback { latch.countDown() }, ) assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") http.shutdown() diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index b76f57c4e..ca61394fe 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -79,7 +79,7 @@ public class PostHogFake : PostHogInterface { ) { } - override fun reloadFeatureFlags(onFeatureFlags: PostHogOnFeatureFlags?) { + override fun reloadFeatureFlags(onFeatureFlags: PostHogFeatureFlagsCallback?) { } override fun isFeatureEnabled( From c9110c6558d0f8213c953903dc667c74f994e203 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 18:13:21 -0400 Subject: [PATCH 2/8] chore: drop the unused server callback wrapper posthog-server calls its own PostHogOnFeatureFlags directly, and the core stateless client never calls config.onFeatureFlags. Bump posthog-server to minor since it now depends on core 7.0, and document that a bootstrapped onFeatureFlags runs on the setup() thread. --- .changeset/server-keep-on-feature-flags.md | 2 +- .../src/main/java/com/posthog/server/PostHogConfig.kt | 2 -- .../src/test/java/com/posthog/server/PostHogConfigTest.kt | 7 ++----- .../main/java/com/posthog/PostHogFeatureFlagsCallback.kt | 3 ++- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.changeset/server-keep-on-feature-flags.md b/.changeset/server-keep-on-feature-flags.md index 611a00d16..ca5fb7278 100644 --- a/.changeset/server-keep-on-feature-flags.md +++ b/.changeset/server-keep-on-feature-flags.md @@ -1,5 +1,5 @@ --- -'posthog-server': patch +'posthog-server': minor --- Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt index 1423c65bc..e0a484bed 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -4,7 +4,6 @@ import com.posthog.BuildConfig import com.posthog.PostHogBeforeSend import com.posthog.PostHogEncryption import com.posthog.PostHogExperimental -import com.posthog.PostHogFeatureFlagsCallback import com.posthog.PostHogIntegration import com.posthog.PostHogOnFeatureFlags import com.posthog.server.internal.PostHogFeatureFlags @@ -312,7 +311,6 @@ public open class PostHogConfig constructor( maxBatchSize = maxBatchSize, flushIntervalSeconds = flushIntervalSeconds, encryption = encryption, - onFeatureFlags = onFeatureFlags?.let { callback -> PostHogFeatureFlagsCallback { callback.loaded() } }, proxy = proxy, remoteConfigProvider = { config, api, _, _, _, _ -> PostHogFeatureFlags( diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt index f04aade97..f77668c76 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt @@ -1,6 +1,5 @@ package com.posthog.server -import com.posthog.PostHogFeatureFlagsLoaded import com.posthog.PostHogOnFeatureFlags import java.net.Proxy import kotlin.test.Test @@ -149,8 +148,7 @@ internal class PostHogConfigTest { @Test fun `asCoreConfig creates core config with all properties`() { val mockEncryption = createMockEncryption() - var onFeatureFlagsCalls = 0 - val mockOnFeatureFlags = PostHogOnFeatureFlags { onFeatureFlagsCalls++ } + val mockOnFeatureFlags = PostHogOnFeatureFlags { } val config = PostHogConfig( @@ -182,8 +180,7 @@ internal class PostHogConfigTest { assertEquals(30, coreConfig.maxBatchSize) assertEquals(45, coreConfig.flushIntervalSeconds) assertEquals(mockEncryption, coreConfig.encryption) - coreConfig.onFeatureFlags!!.loaded(PostHogFeatureFlagsLoaded(emptyMap(), errorsLoading = false)) - assertEquals(1, onFeatureFlagsCalls) + assertNull(coreConfig.onFeatureFlags) } @Test diff --git a/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt index a408dca37..2252f392a 100644 --- a/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt +++ b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt @@ -5,7 +5,8 @@ package com.posthog * * It's used by `PostHogConfig.onFeatureFlags` and `reloadFeatureFlags`. It runs on a background * thread, or right away on the calling thread when no load is attempted (for example, the SDK is - * disabled). Hop to the main thread before touching UI. + * disabled). With bootstrapped flags, `PostHogConfig.onFeatureFlags` also runs once on the thread + * calling `setup()`, before the first load finishes. Hop to the main thread before touching UI. * * Example: * ```kotlin From c49839cb8eaa7a2e0d7dc663a0bbfefb515a3568 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 09:54:02 -0400 Subject: [PATCH 3/8] fix: report persisted flags when the first flags load fails Co-Authored-By: Claude Opus 5.5 --- .../com/posthog/internal/PostHogRemoteConfig.kt | 6 +++++- .../posthog/internal/PostHogRemoteConfigTest.kt | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 8fb661254..6cb87f068 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -290,7 +290,11 @@ public class PostHogRemoteConfig( // because the app might be waiting for it. internalOnFeatureFlags?.let { runSafely(it) } onFeatureFlags?.let { callback -> - runSafely { callback.loaded(PostHogFeatureFlagsLoaded(getFeatureFlags() ?: emptyMap(), errorsLoading)) } + runSafely { + // a failed first load still reports the persisted flags, so hydrate them first + loadFeatureFlagsFromCacheIfNeeded() + callback.loaded(PostHogFeatureFlagsLoaded(getFeatureFlags() ?: emptyMap(), errorsLoading)) + } } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 95914d11a..42bcb5711 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -3110,6 +3110,20 @@ internal class PostHogRemoteConfigTest { assertTrue(loaded.flags.isEmpty()) } + @Test + fun `a failed first load reports the persisted flags`() { + preferences.setValue(FEATURE_FLAGS, mapOf("cached-flag" to true)) + val http = mockHttp(response = MockResponse().setResponseCode(500)) + val sut = getSut(host = http.url("/").toString()) + + val results = LinkedBlockingQueue() + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap(), onFeatureFlags = { results.add(it) }) + + val loaded = results.poll(5, TimeUnit.SECONDS)!! + assertTrue(loaded.errorsLoading) + assertEquals(mapOf("cached-flag" to true), loaded.variants) + } + @Test fun `a throwing internal callback does not suppress the caller's callback`() { val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) From 4bdcb788e6f0297fb68d72f67bb2b1c2abff8979 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 09:54:03 -0400 Subject: [PATCH 4/8] fix: isolate the bootstrap internal flags callback from setup Co-Authored-By: Claude Opus 5.5 --- posthog/src/main/java/com/posthog/PostHog.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 8c636fd1e..63a8c0f65 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -397,7 +397,11 @@ public class PostHog private constructor( // Bootstrap already seeded the flags in RemoteConfig's init; fire the flags-loaded // callbacks now so listeners aren't blocked on the first network response. if (remoteConfig?.hasBootstrapFlags() == true) { - internalOnFeatureFlagsLoaded() + try { + internalOnFeatureFlagsLoaded() + } catch (e: Throwable) { + config.logger.log("Executing the internal feature flags callback failed: $e") + } notifyFeatureFlagsCallback(config.onFeatureFlags, remoteConfig?.getFeatureFlags(), errorsLoading = false) } loadRemoteConfigRequest( From 5dcba7738fdcfad935235f88e17954387cba1c92 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:01:13 -0400 Subject: [PATCH 5/8] fix: keep loaded flags when a flags reload fails Co-Authored-By: Claude Opus 5.5 --- .../posthog/internal/PostHogRemoteConfig.kt | 4 +++- .../internal/PostHogRemoteConfigTest.kt | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 6cb87f068..15d2efd04 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -1282,7 +1282,9 @@ public class PostHogRemoteConfig( } private fun loadFeatureFlagsFromCacheIfNeeded() { - if (!isFeatureFlagsLoaded) { + // after a remote load this session the in-memory flags are newest; a failed reload must not + // hydrate over them, which would also reapply bootstrap + if (!isFeatureFlagsLoaded && !flagsLoadedFromRemote) { loadFeatureFlagsFromCache() } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 42bcb5711..07e7a6601 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -3124,6 +3124,29 @@ internal class PostHogRemoteConfigTest { assertEquals(mapOf("cached-flag" to true), loaded.variants) } + @Test + fun `a failed reload after a successful load reports the loaded flags, not bootstrap`() { + // fixture returns featureFlags {"4535-funnel-bar-viz": true} + val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) + http.enqueue(MockResponse().setBody("")) + val sut = + getSut( + host = http.url("/").toString(), + bootstrap = PostHogBootstrapConfig(featureFlags = mapOf("4535-funnel-bar-viz" to "variant-old")), + ) + + val results = LinkedBlockingQueue() + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap(), onFeatureFlags = { results.add(it) }) + assertEquals(true, results.poll(5, TimeUnit.SECONDS)!!.variants["4535-funnel-bar-viz"]) + + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap(), onFeatureFlags = { results.add(it) }) + + val reloaded = results.poll(5, TimeUnit.SECONDS)!! + assertTrue(reloaded.errorsLoading) + assertEquals(true, reloaded.variants["4535-funnel-bar-viz"]) + assertEquals(true, sut.getFeatureFlag("4535-funnel-bar-viz")) + } + @Test fun `a throwing internal callback does not suppress the caller's callback`() { val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) From 8aaa5a4d0fa4a2a2583b1f60a9ee4697e6a14da7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 18:43:19 -0400 Subject: [PATCH 6/8] fix: retry flag cache hydration while storage is unreadable A failed load now hydrates the flag cache before the callback. Under Direct Boot that read returned empty values and latched, so persisted flags stayed hidden after unlock. Only latch once the preferences are readable. Co-Authored-By: Claude Opus 5.5 --- .../posthog/internal/PostHogRemoteConfig.kt | 5 ++- .../internal/PostHogRemoteConfigTest.kt | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 15d2efd04..7f040ef7b 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -1191,6 +1191,9 @@ public class PostHogRemoteConfig( private fun loadFeatureFlagsFromCache() { config.cachePreferences?.let { preferences -> + // unreadable storage (e.g. Direct Boot) reads as empty: don't latch, so a later read retries + val readable = preferences.isAvailable() + @Suppress("UNCHECKED_CAST") val flags = preferences.getValue( @@ -1226,7 +1229,7 @@ public class PostHogRemoteConfig( this.minimalFlagCalledEvents = cachedMinimalFlagCalledEvents // Bootstrap is an initial snapshot that wins over persisted flags (spec precedence). applyBootstrapSnapshotLocked() - isFeatureFlagsLoaded = true + isFeatureFlagsLoaded = readable } } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 07e7a6601..20ec1cfad 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -3124,6 +3124,50 @@ internal class PostHogRemoteConfigTest { assertEquals(mapOf("cached-flag" to true), loaded.variants) } + // Unreadable until [available] is set, like Android preferences before first unlock (Direct Boot). + private class LockedPreferences(private val delegate: PostHogPreferences) : PostHogPreferences { + @Volatile + var available = false + + override fun getValue( + key: String, + defaultValue: Any?, + ): Any? = if (available) delegate.getValue(key, defaultValue) else defaultValue + + override fun setValue( + key: String, + value: Any, + ) = delegate.setValue(key, value) + + override fun clear(except: List) = delegate.clear(except) + + override fun remove(key: String) = delegate.remove(key) + + override fun getAll(): Map = if (available) delegate.getAll() else emptyMap() + + override fun isAvailable(): Boolean = available + } + + @Test + fun `a failed load while storage is unreadable still serves the persisted flags once readable`() { + val locked = LockedPreferences(PostHogMemoryPreferences().apply { setValue(FEATURE_FLAGS, mapOf("cached-flag" to true)) }) + val offline = + object : PostHogNetworkStatus { + override fun isConnected(): Boolean = false + } + val sut = getSut(host = "http://localhost", networkStatus = offline, cachePreferences = locked) + + val results = LinkedBlockingQueue() + sut.loadRemoteConfig("my_identify", anonymousId = "anonId", emptyMap(), onFeatureFlags = { results.add(it) }) + val loaded = results.poll(5, TimeUnit.SECONDS)!! + assertTrue(loaded.errorsLoading) + assertTrue(loaded.variants.isEmpty()) + + locked.available = true + + assertEquals(true, sut.getFeatureFlag("cached-flag")) + } + @Test fun `a failed reload after a successful load reports the loaded flags, not bootstrap`() { // fixture returns featureFlags {"4535-funnel-bar-viz": true} From f4a6c5351c2ae29cc5515177ce445f0b96bef008 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 10:59:35 -0400 Subject: [PATCH 7/8] fix: skip flag cache reads while storage is unreadable Every flag getter retried hydration under Direct Boot and re-read each cached key, each read failing against locked preferences. Check availability once and skip the reads until storage is readable. Co-Authored-By: Claude Opus 5.5 --- .../posthog/internal/PostHogRemoteConfig.kt | 9 ++++++--- .../internal/PostHogRemoteConfigTest.kt | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 7f040ef7b..2ff9c22b1 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -1191,8 +1191,11 @@ public class PostHogRemoteConfig( private fun loadFeatureFlagsFromCache() { config.cachePreferences?.let { preferences -> - // unreadable storage (e.g. Direct Boot) reads as empty: don't latch, so a later read retries - val readable = preferences.isAvailable() + // unreadable storage (e.g. Direct Boot) would read as empty: skip the reads and don't latch, + // so a later read retries once it's readable + if (!preferences.isAvailable()) { + return + } @Suppress("UNCHECKED_CAST") val flags = @@ -1229,7 +1232,7 @@ public class PostHogRemoteConfig( this.minimalFlagCalledEvents = cachedMinimalFlagCalledEvents // Bootstrap is an initial snapshot that wins over persisted flags (spec precedence). applyBootstrapSnapshotLocked() - isFeatureFlagsLoaded = readable + isFeatureFlagsLoaded = true } } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 20ec1cfad..a38713584 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -3128,11 +3128,18 @@ internal class PostHogRemoteConfigTest { private class LockedPreferences(private val delegate: PostHogPreferences) : PostHogPreferences { @Volatile var available = false + val lockedReads = AtomicInteger() override fun getValue( key: String, defaultValue: Any?, - ): Any? = if (available) delegate.getValue(key, defaultValue) else defaultValue + ): Any? { + if (!available) { + lockedReads.incrementAndGet() + return defaultValue + } + return delegate.getValue(key, defaultValue) + } override fun setValue( key: String, @@ -3168,6 +3175,17 @@ internal class PostHogRemoteConfigTest { assertEquals(true, sut.getFeatureFlag("cached-flag")) } + @Test + fun `flag reads while storage is unreadable don't read the flag cache`() { + val locked = LockedPreferences(PostHogMemoryPreferences().apply { setValue(FEATURE_FLAGS, mapOf("cached-flag" to true)) }) + val sut = getSut(host = "http://localhost", cachePreferences = locked) + val readsAtStartup = locked.lockedReads.get() + + repeat(3) { assertNull(sut.getFeatureFlag("cached-flag")) } + + assertEquals(readsAtStartup, locked.lockedReads.get()) + } + @Test fun `a failed reload after a successful load reports the loaded flags, not bootstrap`() { // fixture returns featureFlags {"4535-funnel-bar-viz": true} From 72602411eae281b27f27da03286e286c62947dbc Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:05:38 -0400 Subject: [PATCH 8/8] fix: don't let cache hydration undo a concurrent reset Hydration reads the cache off the flags lock, so a reset() landing between the read and the commit had the previous identity's flags restored. clear() now bumps a generation and hydration drops a commit from an older one. Co-Authored-By: Claude Opus 5.5 --- .../posthog/internal/PostHogRemoteConfig.kt | 9 +++++++ .../internal/PostHogRemoteConfigTest.kt | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 2ff9c22b1..c1091864a 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -117,6 +117,10 @@ public class PostHogRemoteConfig( @Volatile private var isFeatureFlagsLoaded = false + // Bumped by clear(), so a cache hydration that read the previous identity's flags before a reset + // doesn't commit them after it. Guarded by featureFlagsLock. + private var featureFlagsGeneration = 0 + // Whether a /flags response has been received this session. In-memory and reset each launch // (matching posthog-js), so a returning user still reports $used_bootstrap_value true while served // bootstrap values until this session's own /flags. Distinct from isFeatureFlagsLoaded, which also @@ -1196,6 +1200,7 @@ public class PostHogRemoteConfig( if (!preferences.isAvailable()) { return } + val generation = synchronized(featureFlagsLock) { featureFlagsGeneration } @Suppress("UNCHECKED_CAST") val flags = @@ -1224,6 +1229,9 @@ public class PostHogRemoteConfig( val cachedMinimalFlagCalledEvents = preferences.getValue(MINIMAL_FLAG_CALLED_EVENTS) as? Boolean ?: false synchronized(featureFlagsLock) { + if (generation != featureFlagsGeneration) { + return + } this.flags = flags this.featureFlags = featureFlags this.featureFlagPayloads = payloads @@ -1742,6 +1750,7 @@ public class PostHogRemoteConfig( sessionReplayLinkedFlagConfigured = false consoleLogRecordingEnabled = false isFeatureFlagsLoaded = false + featureFlagsGeneration++ // Bootstrap is first-session only: drop the retained base layer and the "loaded from // remote" signal so a later identity is never served the previous user's bootstrapped // values and $used_bootstrap_value reports true again until its own /flags response. diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index a38713584..c242f2ecb 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -3175,6 +3175,33 @@ internal class PostHogRemoteConfigTest { assertEquals(true, sut.getFeatureFlag("cached-flag")) } + @Test + fun `a reset during cache hydration doesn't restore the previous identity's flags`() { + val stored = PostHogMemoryPreferences().apply { setValue(FEATURE_FLAGS, mapOf("old-user-flag" to true)) } + lateinit var sut: PostHogRemoteConfig + var resetMidRead = false + // stands in for reset() landing between hydration's cache read and its commit + val resetting = + object : PostHogPreferences by stored { + override fun getValue( + key: String, + defaultValue: Any?, + ): Any? { + val value = stored.getValue(key, defaultValue) + if (key == FEATURE_FLAGS && resetMidRead) { + resetMidRead = false + stored.clear() + sut.clear() + } + return value + } + } + sut = getSut(host = "http://localhost", cachePreferences = resetting) + resetMidRead = true + + assertNull(sut.getFeatureFlag("old-user-flag")) + } + @Test fun `flag reads while storage is unreadable don't read the flag cache`() { val locked = LockedPreferences(PostHogMemoryPreferences().apply { setValue(FEATURE_FLAGS, mapOf("cached-flag" to true)) })