From caf64bdbdeba83c86e7162c8e83ddb1763a0674e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 11:55:01 -0400 Subject: [PATCH 01/11] chore!: remove deprecated APIs for posthog-android 4.0 Remove the public APIs already marked deprecated: apiKey and the apiKey factories, propertiesSanitizer and PostHogPropertiesSanitizer, evaluationEnvironments, getFeatureFlagPayload, and the replay debouncerDelayMs. remoteConfig moves to its own change because setting it to false still changes setup behavior. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/android-v4-major.md | 10 ++ posthog-android/api/posthog-android.api | 13 +- .../posthog/android/PostHogAndroidConfig.kt | 36 ----- .../replay/PostHogSessionReplayConfig.kt | 17 --- .../android/PostHogAndroidConfigTest.kt | 12 -- posthog/api/posthog.api | 20 +-- posthog/src/main/java/com/posthog/PostHog.kt | 22 --- .../main/java/com/posthog/PostHogConfig.kt | 132 ------------------ .../main/java/com/posthog/PostHogInterface.kt | 16 --- .../com/posthog/PostHogPropertiesSanitizer.kt | 13 -- .../main/java/com/posthog/PostHogStateless.kt | 5 +- .../java/com/posthog/PostHogConfigTest.kt | 11 -- .../com/posthog/PostHogFeatureFlagsTest.kt | 2 - .../java/com/posthog/PostHogStatelessTest.kt | 35 ----- .../src/test/java/com/posthog/PostHogTest.kt | 49 +------ .../java/com/posthog/PostHogFake.kt | 8 -- 16 files changed, 17 insertions(+), 384 deletions(-) create mode 100644 .changeset/android-v4-major.md delete mode 100644 posthog/src/main/java/com/posthog/PostHogPropertiesSanitizer.kt diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md new file mode 100644 index 000000000..1b6846771 --- /dev/null +++ b/.changeset/android-v4-major.md @@ -0,0 +1,10 @@ +--- +'posthog': major +'posthog-android': major +--- + +- **Breaking:** remove the deprecated `apiKey` property and the `PostHogConfig(apiKey = …)` and `PostHogAndroidConfig(apiKey = …)` factories; use `projectToken` instead +- **Breaking:** remove the deprecated `propertiesSanitizer` config option and the `PostHogPropertiesSanitizer` interface; use `addBeforeSend` instead +- **Breaking:** remove the deprecated `evaluationEnvironments` config option; use `evaluationContexts` instead +- **Breaking:** remove the deprecated `getFeatureFlagPayload()`; use `getFeatureFlagResult(key)?.payload` instead +- **Breaking:** remove the deprecated `PostHogSessionReplayConfig.debouncerDelayMs`; use `throttleDelayMs` instead diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 71c9f8c4a..ee90e68c6 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -17,7 +17,6 @@ public final class com/posthog/android/PostHogAndroid$Companion { } public class com/posthog/android/PostHogAndroidConfig : com/posthog/PostHogConfig { - public static final field Companion Lcom/posthog/android/PostHogAndroidConfig$Companion; public fun (Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;Z)V @@ -47,11 +46,6 @@ public class com/posthog/android/PostHogAndroidConfig : com/posthog/PostHogConfi public final fun setSessionReplayConfig (Lcom/posthog/android/replay/PostHogSessionReplayConfig;)V } -public final class com/posthog/android/PostHogAndroidConfig$Companion { - public final synthetic fun invoke (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;ZZ)Lcom/posthog/android/PostHogAndroidConfig; - public static synthetic fun invoke$default (Lcom/posthog/android/PostHogAndroidConfig$Companion;Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;ZZILjava/lang/Object;)Lcom/posthog/android/PostHogAndroidConfig; -} - public final class com/posthog/android/PostHogAutocaptureModifierKt { public static final fun postHogAutocaptureNoCapture (Landroidx/compose/ui/Modifier;Z)Landroidx/compose/ui/Modifier; public static synthetic fun postHogAutocaptureNoCapture$default (Landroidx/compose/ui/Modifier;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; @@ -130,12 +124,10 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig { public fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;)V public fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;Z)V public fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJ)V - public fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJJ)V - public fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJJLjava/lang/Double;)V - public synthetic fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJJLjava/lang/Double;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJLjava/lang/Double;)V + public synthetic fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJLjava/lang/Double;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCaptureLogcat ()Z public final fun getCaptureTouches ()Z - public final fun getDebouncerDelayMs ()J public final fun getDrawableConverter ()Lcom/posthog/android/replay/PostHogDrawableConverter; public final fun getMaskAllImages ()Z public final fun getMaskAllTextInputs ()Z @@ -148,7 +140,6 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig { public final fun getVerifyScreenshotMaskAlignment ()Z public final fun setCaptureLogcat (Z)V public final fun setCaptureTouches (Z)V - public final fun setDebouncerDelayMs (J)V public final fun setDrawableConverter (Lcom/posthog/android/replay/PostHogDrawableConverter;)V public final fun setMaskAllImages (Z)V public final fun setMaskAllTextInputs (Z)V diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt index 3d188f772..7d406922e 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt @@ -96,40 +96,4 @@ public open class PostHogAndroidConfig public var captureDeadClicks: Boolean = false internal var replayQueueHolder: PostHogReplayQueue? = null - - public companion object { - /** - * Deprecated form of the [PostHogAndroidConfig] constructor that takes `apiKey` instead - * of `projectToken`, kept so Kotlin calls such as `PostHogAndroidConfig(apiKey = "phc_...")` - * still compile. Every other parameter and default matches the constructor. - * - * ```kotlin - * val config = PostHogAndroidConfig(projectToken = "phc_your_project_token") // instead of apiKey = - * ``` - */ - @Deprecated( - "Deprecated in favor of projectToken. This will be removed in the next major version.", - ) - @JvmSynthetic - public operator fun invoke( - apiKey: String, - host: String = PostHogConfig.DEFAULT_HOST, - captureApplicationLifecycleEvents: Boolean = true, - captureDeepLinks: Boolean = true, - captureScreenViews: Boolean = true, - sessionReplayConfig: PostHogSessionReplayConfig = PostHogSessionReplayConfig(), - capturePushNotificationSubscriptions: Boolean = true, - capturePushNotificationOpened: Boolean = true, - ): PostHogAndroidConfig = - PostHogAndroidConfig( - projectToken = apiKey, - host = host, - captureApplicationLifecycleEvents = captureApplicationLifecycleEvents, - captureDeepLinks = captureDeepLinks, - captureScreenViews = captureScreenViews, - sessionReplayConfig = sessionReplayConfig, - capturePushNotificationSubscriptions = capturePushNotificationSubscriptions, - capturePushNotificationOpened = capturePushNotificationOpened, - ) - } } diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt index bc3c492a8..8590f86ec 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt @@ -38,15 +38,6 @@ public class PostHogSessionReplayConfig * The screenshot may contain sensitive information, use with caution. */ public var screenshot: Boolean = false, - /** - * Debouncer delay used to reduce the number of snapshots captured and reduce performance impact. - * This is used for capturing the view as a wireframe or screenshot. - * The lower the number, the more snapshots are captured and the higher the performance impact. - * Defaults to 1000ms = 1s. - * Ps: it was 500ms by default until version 3.8.2. - */ - @Deprecated("Use throttleDelayMs instead") - public var debouncerDelayMs: Long = 1000, /** * Throttling delay used to reduce the number of snapshots captured and reduce performance impact. * This is used for capturing the view as a wireframe or screenshot. @@ -118,12 +109,4 @@ public class PostHogSessionReplayConfig @PostHogExperimental @Volatile public var screenshotColorMode: PostHogScreenshotColorMode = PostHogScreenshotColorMode.ARGB_8888 - - init { - // for keeping back compatibility - @Suppress("DEPRECATION") - if (debouncerDelayMs != 1000L) { - throttleDelayMs = debouncerDelayMs - } - } } diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt index 23d74cd34..ac6bc81c1 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt @@ -21,18 +21,6 @@ internal class PostHogAndroidConfigTest { assertEquals(PROJECT_TOKEN, config.projectToken) } - @Suppress("DEPRECATION") - @Test - fun `deprecated apiKey factory and getter alias projectToken`() { - val config = PostHogAndroidConfig(apiKey = " $PROJECT_TOKEN ", captureDeepLinks = false, capturePushNotificationOpened = false) - - assertEquals(PROJECT_TOKEN, config.projectToken) - assertEquals(PROJECT_TOKEN, config.apiKey) - assertFalse(config.captureDeepLinks) - assertFalse(config.capturePushNotificationOpened) - assertTrue(config.captureScreenViews) - } - @Test fun `captureApplicationLifecycleEvents should be enabled by default`() { assertTrue(config.captureApplicationLifecycleEvents) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 77e664f61..c744204de 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -39,7 +39,6 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun getConfig ()Lcom/posthog/PostHogConfig; public fun getDeviceId ()Ljava/lang/String; public fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;)Ljava/lang/Object; - public fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; public fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/Boolean;)Lcom/posthog/FeatureFlagResult; public fun getLogger ()Lcom/posthog/logs/PostHogLogger; public fun getSessionId ()Ljava/util/UUID; @@ -88,7 +87,6 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun getConfig ()Lcom/posthog/PostHogConfig; public fun getDeviceId ()Ljava/lang/String; public fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;)Ljava/lang/Object; - public fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; public fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/Boolean;)Lcom/posthog/FeatureFlagResult; public fun getLogger ()Lcom/posthog/logs/PostHogLogger; public fun getSessionId ()Ljava/util/UUID; @@ -158,11 +156,10 @@ 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;ZZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/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;ZZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/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;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIIIILcom/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;ZZIIIIIILcom/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 final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V - public final fun getApiKey ()Ljava/lang/String; public final fun getBeforeSendList ()Ljava/util/List; public final fun getBootstrap ()Lcom/posthog/PostHogBootstrapConfig; public final fun getCachePreferences ()Lcom/posthog/internal/PostHogPreferences; @@ -173,7 +170,6 @@ public class com/posthog/PostHogConfig { public final fun getEncryption ()Lcom/posthog/PostHogEncryption; public final fun getErrorTrackingConfig ()Lcom/posthog/errortracking/PostHogErrorTrackingConfig; public final fun getEvaluationContexts ()Ljava/util/List; - public final fun getEvaluationEnvironments ()Ljava/util/List; public final fun getFeatureFlagCalledCacheSize ()I public final fun getFeatureFlagRequestMaxRetries ()I public final fun getFlushAt ()I @@ -196,7 +192,6 @@ public class com/posthog/PostHogConfig { public final fun getPersonProfiles ()Lcom/posthog/PersonProfiles; public final fun getPreloadFeatureFlags ()Z public final fun getProjectToken ()Ljava/lang/String; - public final fun getPropertiesSanitizer ()Lcom/posthog/PostHogPropertiesSanitizer; public final fun getProxy ()Ljava/net/Proxy; public final fun getPushIdentityProvider ()Lkotlin/jvm/functions/Function3; public final fun getQueueProvider ()Lkotlin/jvm/functions/Function5; @@ -229,7 +224,6 @@ public class com/posthog/PostHogConfig { public final fun setDebug (Z)V public final fun setEncryption (Lcom/posthog/PostHogEncryption;)V public final fun setEvaluationContexts (Ljava/util/List;)V - public final fun setEvaluationEnvironments (Ljava/util/List;)V public final fun setFeatureFlagCalledCacheSize (I)V public final fun setFeatureFlagRequestMaxRetries (I)V public final fun setFlushAt (I)V @@ -249,7 +243,6 @@ public class com/posthog/PostHogConfig { public final fun setPersistOptOut (Z)V public final fun setPersonProfiles (Lcom/posthog/PersonProfiles;)V public final fun setPreloadFeatureFlags (Z)V - public final fun setPropertiesSanitizer (Lcom/posthog/PostHogPropertiesSanitizer;)V public final fun setProxy (Ljava/net/Proxy;)V public final fun setPushIdentityProvider (Lkotlin/jvm/functions/Function3;)V public final fun setReleaseIdentifier (Ljava/lang/String;)V @@ -272,8 +265,6 @@ public class com/posthog/PostHogConfig { } public final class com/posthog/PostHogConfig$Companion { - public final synthetic fun invoke (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/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;)Lcom/posthog/PostHogConfig; - public static synthetic fun invoke$default (Lcom/posthog/PostHogConfig$Companion;Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/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;IILjava/lang/Object;)Lcom/posthog/PostHogConfig; } public abstract interface class com/posthog/PostHogCoreInterface { @@ -395,8 +386,6 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun getDeviceId ()Ljava/lang/String; public abstract fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;)Ljava/lang/Object; public static synthetic fun getFeatureFlag$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;ILjava/lang/Object;)Ljava/lang/Object; - public abstract fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; - public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/Boolean;)Lcom/posthog/FeatureFlagResult; public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; public abstract fun getLogger ()Lcom/posthog/logs/PostHogLogger; @@ -441,7 +430,6 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun captureLog$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;ILjava/lang/Object;)V public static synthetic fun capturePushNotificationOpened$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun getFeatureFlag$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; 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 @@ -475,10 +463,6 @@ public abstract interface class com/posthog/PostHogOptInReceiver { public abstract fun onOptIn ()V } -public abstract interface class com/posthog/PostHogPropertiesSanitizer { - public abstract fun sanitize (Ljava/util/Map;)Ljava/util/Map; -} - public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterface { public static final field Companion Lcom/posthog/PostHogStateless$Companion; protected field config Lcom/posthog/PostHogConfig; diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 281503ecb..6bd98ff1e 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -2100,18 +2100,6 @@ public class PostHog private constructor( return results } - @Deprecated( - message = "Use getFeatureFlagResult() instead; note it sends the \$feature_flag_called event by default.", - replaceWith = ReplaceWith("getFeatureFlagResult(key)?.payload"), - ) - public override fun getFeatureFlagPayload( - key: String, - defaultValue: Any?, - ): Any? { - if (!isEnabled()) return defaultValue - return getFeatureFlagResult(key, sendFeatureFlagEvent = false)?.payload ?: defaultValue - } - public override fun getFeatureFlagResult( key: String, sendFeatureFlagEvent: Boolean?, @@ -2748,16 +2736,6 @@ public class PostHog private constructor( return shared.getAllFeatureFlags() } - @Deprecated( - message = "Use getFeatureFlagResult() instead; note it sends the \$feature_flag_called event by default.", - replaceWith = ReplaceWith("getFeatureFlagResult(key)?.payload"), - ) - @Suppress("DEPRECATION") - public override fun getFeatureFlagPayload( - key: String, - defaultValue: Any?, - ): Any? = shared.getFeatureFlagPayload(key, defaultValue = defaultValue) - public override fun getFeatureFlagResult( key: String, sendFeatureFlagEvent: Boolean?, diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index eb730adab..c01078500 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -173,12 +173,6 @@ public open class PostHogConfig( * Defaults to false */ public var sessionReplay: Boolean = false, - /** - * Hook that allows to sanitize the event properties - * The hook is called before the event is cached or sent over the wire - */ - @Deprecated("Use beforeSendList instead") - public var propertiesSanitizer: PostHogPropertiesSanitizer? = null, /** * Hook that allows for modification of the default mechanism for * generating anonymous id (which as of now is just random UUID v7) @@ -446,20 +440,6 @@ public open class PostHogConfig( */ public val projectToken: String = projectToken.trim() - /** - * Deprecated alias for [projectToken]; returns the same trimmed value. - * - * ```kotlin - * val token = config.projectToken // instead of config.apiKey - * ``` - */ - @Deprecated( - "Deprecated in favor of projectToken. This will be removed in the next major version.", - ReplaceWith("projectToken"), - ) - public val apiKey: String - get() = projectToken - /** * The PostHog Host * Defaults to https://us.i.posthog.com @@ -602,20 +582,6 @@ public open class PostHogConfig( } } - /** - * Optional list of evaluation environment tags for feature flag evaluation - * @deprecated Use evaluationContexts instead. This property will be removed in a future version. - */ - @Deprecated( - message = "Use evaluationContexts instead. This property will be removed in a future version.", - replaceWith = ReplaceWith("evaluationContexts"), - ) - public var evaluationEnvironments: List? - get() = evaluationContexts - set(value) { - evaluationContexts = value - } - public companion object { /** Shared default for [flushAt] and [PostHogLogsConfig.flushAt]. */ public const val DEFAULT_FLUSH_AT: Int = 20 @@ -639,104 +605,6 @@ public open class PostHogConfig( public const val DEFAULT_EU_ASSETS_HOST: String = "https://eu-assets.i.posthog.com" public const val DEFAULT_FEATURE_FLAG_CALLED_CACHE_SIZE: Int = 1000 - - /** - * Deprecated form of the [PostHogConfig] constructor that takes `apiKey` instead of - * `projectToken`, kept so Kotlin calls such as `PostHogConfig(apiKey = "phc_...")` still - * compile. Every other parameter and default matches the constructor. - * - * ```kotlin - * val config = PostHogConfig(projectToken = "phc_your_project_token") // instead of apiKey = - * ``` - */ - @Deprecated( - "Deprecated in favor of projectToken. This will be removed in the next major version.", - ) - @Suppress("DEPRECATION") - @JvmSynthetic - public operator fun invoke( - apiKey: String, - host: String = DEFAULT_HOST, - debug: Boolean = false, - optOut: Boolean = false, - sendFeatureFlagEvent: Boolean = true, - featureFlagCalledCacheSize: Int = DEFAULT_FEATURE_FLAG_CALLED_CACHE_SIZE, - preloadFeatureFlags: Boolean = true, - evaluationContexts: List? = null, - setDefaultPersonProperties: Boolean = true, - remoteConfig: Boolean = true, - flushAt: Int = DEFAULT_FLUSH_AT, - maxQueueSize: Int = DEFAULT_MAX_QUEUE_SIZE, - maxBatchSize: Int = DEFAULT_MAX_BATCH_SIZE, - maxRetries: Int = 3, - featureFlagRequestMaxRetries: Int = 1, - flushIntervalSeconds: Int = DEFAULT_FLUSH_INTERVAL_SECONDS, - encryption: PostHogEncryption? = null, - onFeatureFlags: PostHogOnFeatureFlags? = null, - sessionReplay: Boolean = false, - propertiesSanitizer: PostHogPropertiesSanitizer? = null, - getAnonymousId: ((UUID) -> UUID) = { it }, - reuseAnonymousId: Boolean = false, - personProfiles: PersonProfiles = PersonProfiles.IDENTIFIED_ONLY, - surveys: Boolean = false, - proxy: Proxy? = null, - surveysConfig: PostHogSurveysConfig = PostHogSurveysConfig(), - logs: PostHogLogsConfig = PostHogLogsConfig(), - remoteConfigProvider: ( - PostHogConfig, - PostHogApi, - ExecutorService, - PostHogDefaultPersonPropertiesProvider?, - PostHogOnRemoteConfigLoaded?, - PostHogFeatureFlagCalledProvider?, - ) -> PostHogFeatureFlagsInterface = DEFAULT_REMOTE_CONFIG_PROVIDER, - queueProvider: ( - PostHogConfig, - PostHogApi, - PostHogApiEndpoint, - String?, - ExecutorService, - ) -> PostHogQueueInterface = DEFAULT_QUEUE_PROVIDER, - errorTrackingConfig: PostHogErrorTrackingConfig = PostHogErrorTrackingConfig(), - releaseIdentifier: String? = null, - bootstrap: PostHogBootstrapConfig? = null, - pushIdentityProvider: ((distinctId: String, appId: String, completion: (String?) -> Unit) -> Unit)? = null, - ): PostHogConfig = - PostHogConfig( - projectToken = apiKey, - host = host, - debug = debug, - optOut = optOut, - sendFeatureFlagEvent = sendFeatureFlagEvent, - featureFlagCalledCacheSize = featureFlagCalledCacheSize, - preloadFeatureFlags = preloadFeatureFlags, - evaluationContexts = evaluationContexts, - setDefaultPersonProperties = setDefaultPersonProperties, - remoteConfig = remoteConfig, - flushAt = flushAt, - maxQueueSize = maxQueueSize, - maxBatchSize = maxBatchSize, - maxRetries = maxRetries, - featureFlagRequestMaxRetries = featureFlagRequestMaxRetries, - flushIntervalSeconds = flushIntervalSeconds, - encryption = encryption, - onFeatureFlags = onFeatureFlags, - sessionReplay = sessionReplay, - propertiesSanitizer = propertiesSanitizer, - getAnonymousId = getAnonymousId, - reuseAnonymousId = reuseAnonymousId, - personProfiles = personProfiles, - surveys = surveys, - proxy = proxy, - surveysConfig = surveysConfig, - logs = logs, - remoteConfigProvider = remoteConfigProvider, - queueProvider = queueProvider, - errorTrackingConfig = errorTrackingConfig, - releaseIdentifier = releaseIdentifier, - bootstrap = bootstrap, - pushIdentityProvider = pushIdentityProvider, - ) } } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 126d09734..50e76b49b 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -163,22 +163,6 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun getAllFeatureFlags(): List? - /** - * Returns the feature flag payload - * Docs https://posthog.com/docs/feature-flags and https://posthog.com/docs/experiments - * @param key the Key - * @param defaultValue the default value if not found - * @return The feature flag payload, or [defaultValue] if not found. - */ - @Deprecated( - message = "Use getFeatureFlagResult() instead; note it sends the \$feature_flag_called event by default.", - replaceWith = ReplaceWith("getFeatureFlagResult(key)?.payload"), - ) - public fun getFeatureFlagPayload( - key: String, - defaultValue: Any? = null, - ): Any? - /** * Returns the feature flag result containing both value and payload. * This is the recommended way to access feature flags when you need both diff --git a/posthog/src/main/java/com/posthog/PostHogPropertiesSanitizer.kt b/posthog/src/main/java/com/posthog/PostHogPropertiesSanitizer.kt deleted file mode 100644 index 8d4f0d5f1..000000000 --- a/posthog/src/main/java/com/posthog/PostHogPropertiesSanitizer.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.posthog - -/** - * Hook to sanitize the event properties - */ -public fun interface PostHogPropertiesSanitizer { - /** - * Sanitizes the event properties - * @param properties the event properties to sanitize - * @return the sanitized properties - */ - public fun sanitize(properties: MutableMap): Map -} diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index bc54145ad..bd6505876 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -292,20 +292,17 @@ public open class PostHogStateless protected constructor( } } - @Suppress("DEPRECATION") protected fun buildEvent( event: String, distinctId: String, properties: MutableMap, timestamp: Date? = null, ): PostHogEvent? { - // sanitize the properties or fallback to the original properties - val sanitizedProperties = config?.propertiesSanitizer?.sanitize(properties)?.toMutableMap() ?: properties val postHogEvent = PostHogEvent( event, distinctId, - properties = sanitizedProperties, + properties = properties, timestamp = timestamp ?: config?.dateProvider?.currentDate() ?: Date(), ) var eventChecked: PostHogEvent = postHogEvent diff --git a/posthog/src/test/java/com/posthog/PostHogConfigTest.kt b/posthog/src/test/java/com/posthog/PostHogConfigTest.kt index e650bb8a6..7903540c3 100644 --- a/posthog/src/test/java/com/posthog/PostHogConfigTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogConfigTest.kt @@ -23,17 +23,6 @@ internal class PostHogConfigTest { assertEquals("https://eu.i.posthog.com/", config.host) } - @Suppress("DEPRECATION") - @Test - fun `deprecated apiKey factory and getter alias projectToken`() { - val config = PostHogConfig(apiKey = " $PROJECT_TOKEN ", host = "https://eu.i.posthog.com", flushAt = 7) - - assertEquals(PROJECT_TOKEN, config.projectToken) - assertEquals(PROJECT_TOKEN, config.apiKey) - assertEquals("https://eu.i.posthog.com", config.host) - assertEquals(7, config.flushAt) - } - @Test fun `defaults a blank host after trimming whitespace`() { val config = PostHogConfig(PROJECT_TOKEN, " \n\t ") diff --git a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt index 9019adae7..82575e966 100644 --- a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt @@ -54,7 +54,6 @@ internal class PostHogFeatureFlagsTest { integration: PostHogIntegration? = null, remoteConfig: Boolean = false, cachePreferences: PostHogMemoryPreferences = PostHogMemoryPreferences(), - propertiesSanitizer: PostHogPropertiesSanitizer? = null, beforeSend: PostHogBeforeSend? = null, evaluationContexts: List? = null, context: PostHogContext? = null, @@ -73,7 +72,6 @@ internal class PostHogFeatureFlagsTest { this.sendFeatureFlagEvent = sendFeatureFlagEvent this.reuseAnonymousId = reuseAnonymousId this.cachePreferences = cachePreferences - this.propertiesSanitizer = propertiesSanitizer this.evaluationContexts = evaluationContexts this.remoteConfig = remoteConfig if (beforeSend != null) { diff --git a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt index d6c754195..00dddfffb 100644 --- a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt @@ -1087,41 +1087,6 @@ internal class PostHogStatelessTest { assertTrue(mockLogger.messages.any { it.contains("Error in beforeSend function") }) } - @Test - fun `properties sanitizer is applied to events`() { - val mockQueue = MockQueue() - sut = createStatelessInstance() - config = - createConfig().apply { - @Suppress("DEPRECATION") - propertiesSanitizer = - PostHogPropertiesSanitizer { properties -> - properties.apply { - remove("sensitive") - put("sanitized", true) - } - } - } - - sut.setup(config) - sut.setMockQueue(mockQueue) - - sut.captureStateless( - "test", - "user123", - mapOf( - "safe" to "value", - "sensitive" to "secret", - ), - ) - - assertEquals(1, mockQueue.events.size) - val event = mockQueue.events.first() - assertEquals("value", event.properties!!["safe"]) - assertNull(event.properties!!["sensitive"]) - assertEquals(true, event.properties!!["sanitized"]) - } - @Test fun `feature flag called events are sent when feature flags accessed`() { val mockQueue = MockQueue() diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 01286caba..5ce8ff3be 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -97,7 +97,6 @@ internal class PostHogTest { remoteConfig: Boolean = false, surveys: Boolean = false, cachePreferences: PostHogPreferences? = PostHogMemoryPreferences(), - propertiesSanitizer: PostHogPropertiesSanitizer? = null, beforeSend: PostHogBeforeSend? = null, evaluationContexts: List? = null, context: PostHogContext? = null, @@ -121,7 +120,6 @@ internal class PostHogTest { this.sendFeatureFlagEvent = sendFeatureFlagEvent this.reuseAnonymousId = reuseAnonymousId this.cachePreferences = cachePreferences - this.propertiesSanitizer = propertiesSanitizer this.evaluationContexts = evaluationContexts this.remoteConfig = remoteConfig this.surveys = surveys @@ -1009,8 +1007,7 @@ internal class PostHogTest { } @Test - @Suppress("DEPRECATION") - fun `getFeatureFlagPayload returns the value after reloaded`() { + fun `getFeatureFlagResult returns the payload after reloaded`() { val http = mockHttp( response = @@ -1025,7 +1022,7 @@ internal class PostHogTest { remoteConfigExecutor.shutdownAndAwaitTermination() - assertTrue(sut.getFeatureFlagPayload("4535-funnel-bar-viz") as Boolean) + assertTrue(sut.getFeatureFlagResult("4535-funnel-bar-viz", sendFeatureFlagEvent = false)?.payload as Boolean) sut.close() } @@ -3068,48 +3065,6 @@ internal class PostHogTest { assertFalse(config.debug) } - @Suppress("DEPRECATION") - @Test - fun `sanitize properties`() { - val http = mockHttp() - val url = http.url("/") - - val propertiesSanitizer = - PostHogPropertiesSanitizer { properties -> - properties.apply { - remove("prop") - } - } - - val sut = getSut(url.toString(), preloadFeatureFlags = false, propertiesSanitizer = propertiesSanitizer) - - sut.capture( - EVENT, - DISTINCT_ID, - // contains "prop" - props, - userProperties = userProps, - userPropertiesSetOnce = userPropsOnce, - groups = groups, - ) - - queueExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - - assertEquals(1, http.requestCount) - val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - - assertEquals(PROJECT_TOKEN, batch.projectToken) - assertNotNull(batch.sentAt) - - val theEvent = batch.batch.first() - assertNull(theEvent.properties!!["prop"]) - - sut.close() - } - @Test fun `reset preserves the push dedupe id`() { val http = mockHttp() diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index 603a469b8..b76f57c4e 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -98,14 +98,6 @@ public class PostHogFake : PostHogInterface { return null } - @Deprecated("Use getFeatureFlagResult() instead, which returns the flag value and payload from a single evaluation") - override fun getFeatureFlagPayload( - key: String, - defaultValue: Any?, - ): Any? { - return null - } - override fun getFeatureFlagResult( key: String, sendFeatureFlagEvent: Boolean?, From 4d7c1fd4271e57ea5ebaa8b480166b05ed028850 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:09:44 -0400 Subject: [PATCH 02/11] chore!: return null for malformed feature flag payloads (#889) Matches posthog-ios 4.0 and the sdk-specs payload decoding contract. --- .changeset/android-v4-major.md | 1 + .../posthog/internal/PostHogRemoteConfig.kt | 40 ++++--- .../internal/PostHogFeatureFlagsTest.kt | 6 +- .../internal/PostHogFeatureFlagsV1Test.kt | 4 +- .../internal/PostHogRemoteConfigTest.kt | 101 ++++++++++++++++++ 5 files changed, 130 insertions(+), 22 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 1b6846771..e50a3684b 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -8,3 +8,4 @@ - **Breaking:** remove the deprecated `evaluationEnvironments` config option; use `evaluationContexts` instead - **Breaking:** remove the deprecated `getFeatureFlagPayload()`; use `getFeatureFlagResult(key)?.payload` instead - **Breaking:** remove the deprecated `PostHogSessionReplayConfig.debouncerDelayMs`; use `throttleDelayMs` instead +- **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"`) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 03ba9946a..af4d05fc0 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -1,5 +1,8 @@ package com.posthog.internal +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.MalformedJsonException import com.posthog.FeatureFlagResult import com.posthog.PostHogConfig import com.posthog.PostHogInternal @@ -17,6 +20,7 @@ import com.posthog.internal.PostHogPreferences.Companion.PUSH import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY import com.posthog.internal.PostHogPreferences.Companion.SURVEYS import com.posthog.surveys.Survey +import java.io.StringReader import java.util.concurrent.ExecutorService import java.util.concurrent.atomic.AtomicBoolean @@ -1198,26 +1202,28 @@ public class PostHogRemoteConfig( } } - private fun normalizePayloads(featureFlagPayloads: Map?): Map { - val parsedPayloads = (featureFlagPayloads ?: mapOf()).toMutableMap() - - for (item in parsedPayloads) { - val value = item.value + private fun normalizePayloads(featureFlagPayloads: Map?): Map = + (featureFlagPayloads ?: mapOf()).mapValues { (key, value) -> + // payloads arrive as serialized JSON strings; other values are already decoded + if (value is String) decodePayload(key, value) else value + } - try { - // only try to parse if its a String, since the JSON values are stringified - if (value is String) { - // try to deserialize as Any? - config.serializer.deserializeString(value)?.let { - parsedPayloads[item.key] = it - } - } - } catch (ignored: Throwable) { - // if it fails, we keep the original value + // Strict JSON, so a malformed, empty or whitespace-only payload is no payload (null), never the raw string. + private fun decodePayload( + key: String, + serialized: String, + ): Any? = + try { + val reader = JsonReader(StringReader(serialized)) + val decoded = config.serializer.gson.getAdapter(Any::class.java).read(reader) + if (reader.peek() != JsonToken.END_DOCUMENT) { + throw MalformedJsonException("Unexpected data after the JSON value") } + decoded + } catch (e: Throwable) { + config.logger.log("Ignoring malformed payload for feature flag $key: $e") + null } - return parsedPayloads - } private fun normalizeFlagsResponse(flagsResponse: PostHogFlagsResponse): PostHogFlagsResponse { val flags = flagsResponse.flags diff --git a/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt index eec9f7337..a03b735b5 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt @@ -407,7 +407,7 @@ internal class PostHogFeatureFlagsTest { assertTrue(sut.getFeatureFlagPayload("theBoolean", defaultValue = null) as Boolean) assertNull(sut.getFeatureFlagPayload("theNull", defaultValue = null)) - assertEquals("[1, 2", sut.getFeatureFlagPayload("theBroken", defaultValue = null) as String) + assertNull(sut.getFeatureFlagPayload("theBroken", defaultValue = null)) } @Test @@ -465,7 +465,7 @@ internal class PostHogFeatureFlagsTest { """ { "featureFlags": {"flag1": true}, - "featureFlagPayloads": {"flag1": "payload1"} + "featureFlagPayloads": {"flag1": "\"payload1\""} } """.trimIndent(), ), @@ -515,7 +515,7 @@ internal class PostHogFeatureFlagsTest { """ { "featureFlags": {"flag1": true}, - "featureFlagPayloads": {"flag1": "payload1"} + "featureFlagPayloads": {"flag1": "\"payload1\""} } """.trimIndent(), ), diff --git a/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsV1Test.kt index 7cb40c827..fd2fa4220 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsV1Test.kt @@ -270,7 +270,7 @@ internal class PostHogFeatureFlagsV1Test { assertTrue(sut.getFeatureFlagPayload("theBoolean", defaultValue = null) as Boolean) assertNull(sut.getFeatureFlagPayload("theNull", defaultValue = null)) - assertEquals("[1, 2", sut.getFeatureFlagPayload("theBroken", defaultValue = null) as String) + assertNull(sut.getFeatureFlagPayload("theBroken", defaultValue = null)) } @Test @@ -328,7 +328,7 @@ internal class PostHogFeatureFlagsV1Test { """ { "featureFlags": {"flag1": true}, - "featureFlagPayloads": {"flag1": "payload1"} + "featureFlagPayloads": {"flag1": "\"payload1\""} } """.trimIndent(), ), diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 5701d46e4..0d9249fc1 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -3084,4 +3084,105 @@ internal class PostHogRemoteConfigTest { sut.clear() http.shutdown() } + + private fun loadFlagsWithPayloads( + payloads: Map, + logs: MutableList = mutableListOf(), + ): PostHogRemoteConfig { + val serializedPayloads = payloads.entries.joinToString(",") { (key, value) -> "\"$key\":${jsonString(value)}" } + val flags = payloads.keys.joinToString(",") { "\"$it\":\"blue\"" } + val http = + mockHttp( + response = + MockResponse().setBody( + """{"errorsWhileComputingFlags":false,"featureFlags":{$flags,"beta-ui":true},""" + + """"featureFlagPayloads":{$serializedPayloads,"beta-ui":"{\"color\":\"green\"}"}}""", + ), + ) + val sut = getSut(host = http.url("/").toString()) + config!!.logger = + object : PostHogLogger { + override fun log(message: String) { + logs.add(message) + } + + override fun isEnabled(): Boolean = true + } + + val latch = CountDownLatch(1) + sut.loadFeatureFlags( + "my_identify", + anonymousId = "anonId", + emptyMap(), + onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + ) + assertTrue(latch.await(5, TimeUnit.SECONDS), "flags load should complete") + http.shutdown() + return sut + } + + private fun jsonString(value: String): String = PostHogSerializer(PostHogConfig(PROJECT_TOKEN)).gson.toJson(value) + + @Test + fun `malformed, empty or whitespace payloads are null and keep the flag and sibling payloads`() { + for (input in listOf("{broken", "", " ", "hello", "{\"a\":1} trailing")) { + preferences.clear() + val logs = mutableListOf() + val sut = loadFlagsWithPayloads(mapOf("checkout" to input), logs) + + assertNull(sut.getFeatureFlagPayload("checkout"), "payload for input '$input'") + val result = sut.getFeatureFlagResult("checkout") + assertNotNull(result) + assertTrue(result.enabled) + assertEquals("blue", result.variant) + assertNull(result.payload) + assertEquals(mapOf("checkout" to "blue", "beta-ui" to true), sut.getFeatureFlags()) + assertEquals(mapOf("color" to "green"), sut.getFeatureFlagPayload("beta-ui")) + assertTrue(logs.any { "checkout" in it && "malformed" in it }, "logs for input '$input': $logs") + + // the cache holds no raw string either, so a later launch reads no payload + assertNull(getSut(host = "http://127.0.0.1:1/").getFeatureFlagPayload("checkout")) + } + } + + @Test + fun `valid JSON payloads keep their decoded value`() { + val sut = + loadFlagsWithPayloads( + mapOf( + "string" to "\"hello\"", + "empty" to "\"\"", + "false" to "false", + "zero" to "0", + "null" to "null", + "object" to "{\"color\":\"green\"}", + "array" to "[1,false]", + ), + ) + + assertEquals("hello", sut.getFeatureFlagPayload("string")) + assertEquals("", sut.getFeatureFlagPayload("empty")) + assertEquals(false, sut.getFeatureFlagPayload("false")) + assertEquals(0, sut.getFeatureFlagPayload("zero")) + assertNull(sut.getFeatureFlagPayload("null")) + assertEquals(mapOf("color" to "green"), sut.getFeatureFlagPayload("object")) + assertEquals(listOf(1, false), sut.getFeatureFlagPayload("array")) + } + + @Test + fun `bootstrapped string payloads are not JSON-decoded again`() { + for (payload in listOf("hello", "123", "true")) { + val sut = + getSut( + host = "http://127.0.0.1:1/", + bootstrap = + PostHogBootstrapConfig( + featureFlags = mapOf("beta-ui" to "variant-a"), + featureFlagPayloads = mapOf("beta-ui" to payload), + ), + ) + + assertEquals(payload, sut.getFeatureFlagResult("beta-ui")?.payload) + } + } } From ec06e867c6dae6181dce9bb9ab648ac01bc017fc Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:06:10 -0400 Subject: [PATCH 03/11] chore!: make SDK-internal helpers non-public (#872) * chore!: make SDK-internal helpers non-public for posthog-android 4.0 Make internal the public declarations that no other module in this repo and no wrapper (Flutter, React Native, KMP) uses. LogLine and LogcatParser are Java, so they become package-private. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep GzipRequestInterceptor public for the compliance adapter sdk_compliance_adapter is a separate Gradle module, only included by its Dockerfile, and constructs GzipRequestInterceptor directly. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .changeset/android-v4-major.md | 1 + posthog-android/api/posthog-android.api | 19 -------- .../android/internal/PostHogAndroidUtils.kt | 6 +-- .../android/replay/internal/LogLine.java | 4 +- .../android/replay/internal/LogcatParser.java | 4 +- posthog/api/posthog.api | 45 ------------------- .../internal/CustomHeadersInterceptor.kt | 4 +- .../com/posthog/internal/PostHogContext.kt | 3 +- .../PostHogLocalEvaluationDeserializers.kt | 7 +-- .../posthog/internal/PostHogPreferences.kt | 3 +- .../posthog/internal/PostHogPrintLogger.kt | 4 +- .../errortracking/ThrowableCoercer.kt | 4 +- .../posthog/internal/replay/RRDocumentNode.kt | 5 +-- .../replay/RRDomContentLoadedEvent.kt | 5 +-- .../posthog/internal/replay/RRLoadedEvent.kt | 5 +-- .../surveys/SurveyLanguageDetection.kt | 3 +- 16 files changed, 16 insertions(+), 106 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index e50a3684b..5a2892275 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -9,3 +9,4 @@ - **Breaking:** remove the deprecated `getFeatureFlagPayload()`; use `getFeatureFlagResult(key)?.payload` instead - **Breaking:** remove the deprecated `PostHogSessionReplayConfig.debouncerDelayMs`; use `throttleDelayMs` instead - **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 diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index ee90e68c6..1749b4128 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -70,9 +70,6 @@ public final class com/posthog/android/internal/PostHogAndroidUtilsKt { public static final fun base64 (Landroid/graphics/Bitmap;Landroid/graphics/Bitmap$CompressFormat;I)Ljava/lang/String; public static synthetic fun base64$default (Landroid/graphics/Bitmap;Landroid/graphics/Bitmap$CompressFormat;IILjava/lang/Object;)Ljava/lang/String; public static final fun getApplicationInfo (Landroid/content/Context;)Landroid/content/pm/ApplicationInfo; - public static final fun isValid (Landroid/graphics/Bitmap;)Z - public static final fun webpBase64 (Landroid/graphics/Bitmap;I)Ljava/lang/String; - public static synthetic fun webpBase64$default (Landroid/graphics/Bitmap;IILjava/lang/Object;)Ljava/lang/String; } public abstract interface class com/posthog/android/replay/PostHogDrawableConverter { @@ -152,22 +149,6 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig { public final fun setVerifyScreenshotMaskAlignment (Z)V } -public class com/posthog/android/replay/internal/LogLine { - public field level Ljava/lang/String; - public field tag Ljava/lang/String; - public field text Ljava/lang/String; - public field time Ljava/util/GregorianCalendar; - public fun ()V -} - -public class com/posthog/android/replay/internal/LogcatParser { - public static final field BUFFER_BEGIN_RE Ljava/util/regex/Pattern; - public static final field DATE_TIME_MS_PATTERN Ljava/lang/String; - public static final field UTC Ljava/util/TimeZone; - public fun ()V - public fun parse (Ljava/lang/String;)Lcom/posthog/android/replay/internal/LogLine; -} - public final class com/posthog/android/surveys/PostHogSurveysIntegration : com/posthog/PostHogIntegration, com/posthog/internal/surveys/PostHogSurveysHandler { public fun (Landroid/content/Context;Lcom/posthog/PostHogConfig;)V public fun install (Lcom/posthog/PostHogInterface;)V diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt index fd065353f..2468c870f 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt @@ -196,8 +196,7 @@ public fun getApplicationInfo(context: Context): ApplicationInfo = .getApplicationInfo(context.packageName, GET_META_DATA) } -@PostHogInternal -public fun Bitmap.isValid(): Boolean { +internal fun Bitmap.isValid(): Boolean { return !isRecycled && width > 0 && height > 0 @@ -225,9 +224,8 @@ private fun Bitmap.base64BufferSize(): Int = .coerceAtLeast(4L * 1024) .toInt() -@PostHogInternal @Suppress("DEPRECATION") -public fun Bitmap.webpBase64(quality: Int = 30): String? { +internal fun Bitmap.webpBase64(quality: Int = 30): String? { if (!isValid()) { return null } diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/LogLine.java b/posthog-android/src/main/java/com/posthog/android/replay/internal/LogLine.java index b85e3c251..249b00b79 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/internal/LogLine.java +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/LogLine.java @@ -2,15 +2,13 @@ package com.posthog.android.replay.internal; -import com.posthog.PostHogInternal; import java.util.GregorianCalendar; /** * A log line. */ -@PostHogInternal -public class LogLine { +class LogLine { /** * The timestamp of the event, parsed directly from Unix epoch time and represented in UTC. */ diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/LogcatParser.java b/posthog-android/src/main/java/com/posthog/android/replay/internal/LogcatParser.java index 69c1d2e7a..b2cc5a9aa 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/internal/LogcatParser.java +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/LogcatParser.java @@ -2,7 +2,6 @@ package com.posthog.android.replay.internal; -import com.posthog.PostHogInternal; import java.util.GregorianCalendar; import java.util.TimeZone; @@ -10,8 +9,7 @@ import java.util.regex.Pattern; /** Parses logcat output formatted with {@code -v epoch}. */ -@PostHogInternal -public class LogcatParser { +class LogcatParser { /** UTC Time Zone. */ public static final TimeZone UTC = TimeZone.getTimeZone("UTC"); diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index c744204de..1d18f638e 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -606,11 +606,6 @@ public final class com/posthog/errortracking/PostHogExceptionStepsConfig { public final fun setMaxBytes (I)V } -public final class com/posthog/internal/CustomHeadersInterceptor : okhttp3/Interceptor { - public fun (Lcom/posthog/PostHogConfig;)V - public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; -} - public final class com/posthog/internal/EndpointSpec { public static final field Companion Lcom/posthog/internal/EndpointSpec$Companion; public static final fun batch (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; @@ -827,10 +822,6 @@ public abstract interface class com/posthog/internal/PostHogContext { public abstract fun getStaticContext ()Ljava/util/Map; } -public final class com/posthog/internal/PostHogContextKt { - public static final fun personPropertiesContext (Lcom/posthog/internal/PostHogContext;)Ljava/util/Map; -} - public abstract interface class com/posthog/internal/PostHogDateProvider { public abstract fun addSecondsToCurrentDate (I)Ljava/util/Date; public abstract fun currentDate ()Ljava/util/Date; @@ -963,7 +954,6 @@ public abstract interface class com/posthog/internal/PostHogPreferences { public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; public static final field PUSH_OPENED_MESSAGE_IDS Ljava/lang/String; - public static final field PUSH_SUBSCRIPTION_REJECTED Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; public static final field SURVEY_PROGRESS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; @@ -986,7 +976,6 @@ public final class com/posthog/internal/PostHogPreferences$Companion { public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; public static final field PUSH_OPENED_MESSAGE_IDS Ljava/lang/String; - public static final field PUSH_SUBSCRIPTION_REJECTED Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; public static final field SURVEY_PROGRESS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; @@ -1000,13 +989,6 @@ public final class com/posthog/internal/PostHogPreferences$DefaultImpls { public static fun isAvailable (Lcom/posthog/internal/PostHogPreferences;)Z } -public final class com/posthog/internal/PostHogPrintLogger : com/posthog/internal/PostHogLogger { - public fun (Lcom/posthog/PostHogConfig;)V - public fun isEnabled ()Z - public fun log (Ljava/lang/String;)V - public fun logWarning (Ljava/lang/String;)V -} - public final class com/posthog/internal/PostHogQueue : com/posthog/internal/PostHogQueueInterface { public fun (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/EndpointSpec;Ljava/util/concurrent/ExecutorService;)V public fun add (Ljava/lang/Object;)V @@ -1151,12 +1133,6 @@ public final class com/posthog/internal/PropertyGroup { public fun toString ()Ljava/lang/String; } -public final class com/posthog/internal/PropertyGroupDeserializer : com/google/gson/JsonDeserializer { - public fun ()V - public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lcom/posthog/internal/PropertyGroup; - public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object; -} - public final class com/posthog/internal/PropertyOperator : java/lang/Enum { public static final field Companion Lcom/posthog/internal/PropertyOperator$Companion; public static final field ENDS_WITH Lcom/posthog/internal/PropertyOperator; @@ -1242,12 +1218,6 @@ public final class com/posthog/internal/PropertyValue$PropertyGroups : com/posth public fun toString ()Ljava/lang/String; } -public final class com/posthog/internal/PropertyValueDeserializer : com/google/gson/JsonDeserializer { - public fun ()V - public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lcom/posthog/internal/PropertyValue; - public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object; -} - public final class com/posthog/internal/VariantDefinition { public fun (Ljava/lang/String;D)V public final fun getKey ()Ljava/lang/String; @@ -1260,8 +1230,6 @@ public final class com/posthog/internal/errortracking/ThrowableCoercer { public static final field MAX_EXCEPTION_LIST_SIZE I public static final field MAX_FRAMES_PER_STACKTRACE I public fun ()V - public final fun fromThrowableToPostHogProperties (Ljava/lang/Throwable;Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/Map; - public static synthetic fun fromThrowableToPostHogProperties$default (Lcom/posthog/internal/errortracking/ThrowableCoercer;Ljava/lang/Throwable;Ljava/util/List;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Ljava/util/Map; } public abstract interface class com/posthog/internal/replay/PostHogSessionReplayHandler { @@ -1282,14 +1250,6 @@ public final class com/posthog/internal/replay/RRCustomEvent : com/posthog/inter public fun (Ljava/lang/String;Ljava/lang/Object;J)V } -public final class com/posthog/internal/replay/RRDocumentNode : com/posthog/internal/replay/RREvent { - public fun (Ljava/lang/String;Ljava/lang/Object;J)V -} - -public final class com/posthog/internal/replay/RRDomContentLoadedEvent : com/posthog/internal/replay/RREvent { - public fun (J)V -} - public class com/posthog/internal/replay/RREvent { public fun (Lcom/posthog/internal/replay/RREventType;JLjava/lang/Object;)V public synthetic fun (Lcom/posthog/internal/replay/RREventType;JLjava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -1382,10 +1342,6 @@ public final class com/posthog/internal/replay/RRIncrementalSource$Companion { public final fun fromValue (I)Lcom/posthog/internal/replay/RRIncrementalSource; } -public final class com/posthog/internal/replay/RRLoadedEvent : com/posthog/internal/replay/RREvent { - public fun (J)V -} - public final class com/posthog/internal/replay/RRMetaEvent : com/posthog/internal/replay/RREvent { public fun (IIJLjava/lang/String;)V } @@ -1582,7 +1538,6 @@ public final class com/posthog/internal/surveys/ResolvedSurveyTranslations { public final class com/posthog/internal/surveys/SurveyLanguageDetectionKt { public static final fun detectSurveyLanguage (Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;)Ljava/lang/String; - public static final fun findBestTranslationMatch (Ljava/util/Map;Ljava/lang/String;)Ljava/lang/String; } public final class com/posthog/internal/surveys/SurveyTranslationApplierKt { diff --git a/posthog/src/main/java/com/posthog/internal/CustomHeadersInterceptor.kt b/posthog/src/main/java/com/posthog/internal/CustomHeadersInterceptor.kt index 498937756..74cd4b3ef 100644 --- a/posthog/src/main/java/com/posthog/internal/CustomHeadersInterceptor.kt +++ b/posthog/src/main/java/com/posthog/internal/CustomHeadersInterceptor.kt @@ -1,7 +1,6 @@ package com.posthog.internal import com.posthog.PostHogConfig -import com.posthog.PostHogInternal import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.Request @@ -13,8 +12,7 @@ import java.io.IOException * reverse proxy) to every request the SDK sends. * @property config The Config */ -@PostHogInternal -public class CustomHeadersInterceptor(private val config: PostHogConfig) : Interceptor { +internal class CustomHeadersInterceptor(private val config: PostHogConfig) : Interceptor { private companion object { // SDK-managed headers that custom values can't override (compared lowercase). private val RESERVED_HEADERS = setOf("content-type", "user-agent", "accept-encoding", "content-encoding") diff --git a/posthog/src/main/java/com/posthog/internal/PostHogContext.kt b/posthog/src/main/java/com/posthog/internal/PostHogContext.kt index f0377d21d..ab7a528e8 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogContext.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogContext.kt @@ -19,8 +19,7 @@ public interface PostHogContext { * Returns person properties context by extracting relevant properties from static context. * This centralizes the logic for determining which properties should be used as person properties. */ -@PostHogInternal -public fun PostHogContext.personPropertiesContext(): Map { +internal fun PostHogContext.personPropertiesContext(): Map { val sdkInfo = getSdkInfo() val staticCtx = getStaticContext() val personProperties = mutableMapOf() diff --git a/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationDeserializers.kt b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationDeserializers.kt index 2b5ec9df4..00117cbbc 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationDeserializers.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationDeserializers.kt @@ -6,14 +6,12 @@ import com.google.gson.JsonElement import com.google.gson.JsonSerializationContext import com.google.gson.JsonSerializer import com.google.gson.stream.MalformedJsonException -import com.posthog.PostHogInternal import java.lang.reflect.Type /** * Gson deserializer for PropertyGroup */ -@PostHogInternal -public class PropertyGroupDeserializer : JsonDeserializer { +internal class PropertyGroupDeserializer : JsonDeserializer { override fun deserialize( json: JsonElement, typeOfT: Type, @@ -43,8 +41,7 @@ public class PropertyGroupDeserializer : JsonDeserializer { * Gson deserializer for PropertyValue sealed interface * Determines whether to deserialize as PropertyGroups or FlagProperties based on structure */ -@PostHogInternal -public class PropertyValueDeserializer : JsonDeserializer { +internal class PropertyValueDeserializer : JsonDeserializer { override fun deserialize( json: JsonElement, typeOfT: Type, diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index 84cf4d343..9d29d9f99 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -56,8 +56,7 @@ public interface PostHogPreferences { @PostHogInternal public const val PUSH_OPENED_MESSAGE_IDS: String = "pushOpenedMessageIds" - @PostHogInternal - public const val PUSH_SUBSCRIPTION_REJECTED: String = "pushSubscriptionRejected" + internal const val PUSH_SUBSCRIPTION_REJECTED: String = "pushSubscriptionRejected" internal const val PERSON_PROPERTIES_FOR_FLAGS = "personPropertiesForFlags" internal const val GROUP_PROPERTIES_FOR_FLAGS = "groupPropertiesForFlags" public const val SURVEY_PROGRESS: String = "surveyProgress" diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPrintLogger.kt b/posthog/src/main/java/com/posthog/internal/PostHogPrintLogger.kt index babf8f72d..4c2d1f643 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPrintLogger.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPrintLogger.kt @@ -1,14 +1,12 @@ package com.posthog.internal import com.posthog.PostHogConfig -import com.posthog.PostHogInternal /** * Logs the messages using System.out only if config.debug is enabled * @property config the Config */ -@PostHogInternal -public class PostHogPrintLogger(private val config: PostHogConfig) : PostHogLogger { +internal class PostHogPrintLogger(private val config: PostHogConfig) : PostHogLogger { override fun log(message: String) { // isEnabled can be abstracted in another class (refactor needed). if (isEnabled()) { diff --git a/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt b/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt index 89b18a828..fb67d2880 100644 --- a/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt +++ b/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt @@ -244,9 +244,7 @@ public class ThrowableCoercer { return exception } - // `inAppExcludes` is appended after `releaseIdentifier` so existing positional Kotlin calls stay - // valid. This is a `@PostHogInternal` entry point, so the JVM descriptor change is fine. - public fun fromThrowableToPostHogProperties( + internal fun fromThrowableToPostHogProperties( throwable: Throwable, inAppIncludes: List = listOf(), releaseIdentifier: String? = null, diff --git a/posthog/src/main/java/com/posthog/internal/replay/RRDocumentNode.kt b/posthog/src/main/java/com/posthog/internal/replay/RRDocumentNode.kt index d45502d16..c635252d3 100644 --- a/posthog/src/main/java/com/posthog/internal/replay/RRDocumentNode.kt +++ b/posthog/src/main/java/com/posthog/internal/replay/RRDocumentNode.kt @@ -1,9 +1,6 @@ package com.posthog.internal.replay -import com.posthog.PostHogInternal - -@PostHogInternal -public class RRDocumentNode(tag: String, payload: Any, timestamp: Long) : RREvent( +internal class RRDocumentNode(tag: String, payload: Any, timestamp: Long) : RREvent( type = RREventType.Custom, data = mapOf( diff --git a/posthog/src/main/java/com/posthog/internal/replay/RRDomContentLoadedEvent.kt b/posthog/src/main/java/com/posthog/internal/replay/RRDomContentLoadedEvent.kt index 6b550d57d..275be367f 100644 --- a/posthog/src/main/java/com/posthog/internal/replay/RRDomContentLoadedEvent.kt +++ b/posthog/src/main/java/com/posthog/internal/replay/RRDomContentLoadedEvent.kt @@ -1,9 +1,6 @@ package com.posthog.internal.replay -import com.posthog.PostHogInternal - -@PostHogInternal -public class RRDomContentLoadedEvent(timestamp: Long) : RREvent( +internal class RRDomContentLoadedEvent(timestamp: Long) : RREvent( type = RREventType.DomContentLoaded, timestamp = timestamp, ) diff --git a/posthog/src/main/java/com/posthog/internal/replay/RRLoadedEvent.kt b/posthog/src/main/java/com/posthog/internal/replay/RRLoadedEvent.kt index 31508abd4..3b02f7844 100644 --- a/posthog/src/main/java/com/posthog/internal/replay/RRLoadedEvent.kt +++ b/posthog/src/main/java/com/posthog/internal/replay/RRLoadedEvent.kt @@ -1,9 +1,6 @@ package com.posthog.internal.replay -import com.posthog.PostHogInternal - -@PostHogInternal -public class RRLoadedEvent(timestamp: Long) : RREvent( +internal class RRLoadedEvent(timestamp: Long) : RREvent( type = RREventType.Load, timestamp = timestamp, ) diff --git a/posthog/src/main/java/com/posthog/internal/surveys/SurveyLanguageDetection.kt b/posthog/src/main/java/com/posthog/internal/surveys/SurveyLanguageDetection.kt index 9f2186e7b..a8f3814f9 100644 --- a/posthog/src/main/java/com/posthog/internal/surveys/SurveyLanguageDetection.kt +++ b/posthog/src/main/java/com/posthog/internal/surveys/SurveyLanguageDetection.kt @@ -36,8 +36,7 @@ public fun detectSurveyLanguage( * Returns the original-cased key from [translations] (so it can be reported verbatim * as `$survey_language`), or `null` if no match. */ -@PostHogInternal -public fun findBestTranslationMatch( +internal fun findBestTranslationMatch( translations: Map?, targetLanguage: String?, ): String? { From ead5a23ce12b90c2d886535dba48458149a2718e Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:24:57 -0400 Subject: [PATCH 04/11] chore!: remove the deprecated remoteConfig option (#874) Setup now always loads /config. With remoteConfig = false, setup used to skip /config, which left remote settings unloaded since /flags stopped returning config. Co-authored-by: Claude Opus 5.5 (1M context) --- .changeset/android-v4-major.md | 1 + .../compose/internal/PostHogSurveyHostTest.kt | 1 - .../replay/PostHogReplayIntegration.kt | 10 +--- .../android/PostHogAndroidAppVersionTest.kt | 3 +- .../PostHogAndroidEventSnapshotsTest.kt | 12 +++-- .../internal/InteractionActivityTest.kt | 4 +- .../InteractionDetectorActivityTest.kt | 3 +- .../InteractionDetectorIntegrationTest.kt | 6 +-- .../replay/PostHogReplayIntegrationTest.kt | 51 +------------------ .../surveys/PostHogSurveysRestartTest.kt | 1 - .../java/com/posthog/server/PostHogConfig.kt | 1 - .../com/posthog/server/PostHogConfigTest.kt | 1 - posthog/api/posthog.api | 6 +-- posthog/src/main/java/com/posthog/PostHog.kt | 14 ++--- .../main/java/com/posthog/PostHogConfig.kt | 9 ---- .../java/com/posthog/PostHogBeforeSendTest.kt | 1 - .../java/com/posthog/PostHogBootstrapTest.kt | 9 ++-- .../com/posthog/PostHogFeatureFlagsTest.kt | 5 +- .../posthog/PostHogIntegrationChangeTest.kt | 3 +- .../PostHogIntegrationInstallFailureTest.kt | 3 +- .../posthog/PostHogOkHttpInterceptorTest.kt | 1 - .../src/test/java/com/posthog/PostHogTest.kt | 48 ++++++++--------- 22 files changed, 51 insertions(+), 142 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 5a2892275..e89b373a7 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -10,3 +10,4 @@ - **Breaking:** remove the deprecated `PostHogSessionReplayConfig.debouncerDelayMs`; use `throttleDelayMs` instead - **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 diff --git a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt index 16b3ee565..fe5b5a8a1 100644 --- a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt +++ b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt @@ -279,7 +279,6 @@ internal class PostHogSurveyHostTest { PostHogConfig("host-reset", "http://127.0.0.1:1").apply { cachePreferences = PostHogMemoryPreferences() preloadFeatureFlags = false - remoteConfig = false reuseAnonymousId = true } } diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index daec3775b..46af13767 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -3054,15 +3054,9 @@ public class PostHogReplayIntegration( } // Whether to buffer until the live remote config resolves: only when a remote config holder - // exists and hasn't resolved yet, AND a fetch will actually be attempted at setup. If neither - // remoteConfig nor preloadFeatureFlags is enabled, PostHog.setup dispatches no /config or /flags - // request, so no onRemoteConfig callback ever arrives to disarm the gate — - // buffering would then grow unbounded for the whole session. Don't arm in that case. + // exists and hasn't resolved yet. PostHog.setup always requests /config, so the + // onRemoteConfig callback disarms the gate. private fun shouldAwaitFirstRemoteConfig(): Boolean { - @Suppress("DEPRECATION") - if (!config.remoteConfig && !config.preloadFeatureFlags) { - return false - } return config.remoteConfigHolder?.let { !it.hasRemoteConfigFetched() } ?: false } diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidAppVersionTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidAppVersionTest.kt index 9b16bcb53..68518c7df 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidAppVersionTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidAppVersionTest.kt @@ -43,10 +43,9 @@ internal class PostHogAndroidAppVersionTest { events: MutableList, configure: PostHogAndroidConfig.() -> Unit = {}, ): PostHogAndroidConfig = - PostHogAndroidConfig(projectToken).apply { + PostHogAndroidConfig(projectToken, "http://127.0.0.1:1").apply { cachePreferences = preferences captureApplicationLifecycleEvents = captureLifecycle - remoteConfig = false preloadFeatureFlags = false configure() addBeforeSend { 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 e59c0977c..ae466718a 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt @@ -94,7 +94,7 @@ internal class PostHogAndroidEventSnapshotsTest { sut.captureException(fixedThrowable(), mapOf("handled" to true, "component" to "checkout")) sut.flush() - val request = fixture.server.takeRequests("/flags/?v=2", "/batch").single { it.path == "/batch" } + val request = fixture.server.takeRequests(CONFIG_PATH, "/flags/?v=2", "/batch").single { it.path == "/batch" } val snapshot = requestSnapshot(request, fixture.config) normalizeBatchVolatileFields(snapshot) @@ -118,7 +118,7 @@ internal class PostHogAndroidEventSnapshotsTest { fixture.client.reloadFeatureFlags(PostHogOnFeatureFlags { loaded.countDown() }) assertTrue(loaded.await(10, TimeUnit.SECONDS), "Feature flags request did not finish") - val request = fixture.server.takeRequests("/flags/?v=2").single() + val request = fixture.server.takeRequests(CONFIG_PATH, "/flags/?v=2").single { it.path == "/flags/?v=2" } assertSnapshot("flags-request.json", requestSnapshot(request, fixture.config), fixture.config) } @@ -152,7 +152,7 @@ internal class PostHogAndroidEventSnapshotsTest { ) fixture.client.flush() - val request = fixture.server.takeRequests("/s/").single() + val request = fixture.server.takeRequests(CONFIG_PATH, "/s/").single { it.path == "/s/" } val snapshot = requestSnapshot(request, fixture.config) normalizeReplayVolatileFields(snapshot) @@ -163,6 +163,8 @@ internal class PostHogAndroidEventSnapshotsTest { mockContextAppStart(context, tmpDir) val server = MockWebServer().apply { + // setup always requests /config first + enqueue(MockResponse().setBody(CONFIG_RESPONSE).setHeader("Content-Type", "application/json")) repeat(10) { enqueue(MockResponse().setBody(FLAGS_RESPONSE).setHeader("Content-Type", "application/json")) } @@ -185,8 +187,6 @@ internal class PostHogAndroidEventSnapshotsTest { flushIntervalSeconds = 60 maxBatchSize = 20 preloadFeatureFlags = false - @Suppress("DEPRECATION") - remoteConfig = false captureApplicationLifecycleEvents = false captureDeepLinks = false captureScreenViews = false @@ -412,6 +412,8 @@ internal class PostHogAndroidEventSnapshotsTest { private val FIXED_DATE = Date(FIXED_MILLIS) private val SESSION_ID = UUID.fromString("018bcfe5-687b-7abc-8def-0123456789ab") private const val FIXED_TIMESTAMP = "2023-11-14T22:13:20.123Z" + private const val CONFIG_PATH = "/array/$PROJECT_TOKEN/config" + private const val CONFIG_RESPONSE = """{"hasFeatureFlags":true}""" private const val FLAGS_RESPONSE = """{ "flags": { diff --git a/posthog-android/src/test/java/com/posthog/android/internal/InteractionActivityTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/InteractionActivityTest.kt index 9e7a8dc2b..f888f61e3 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/InteractionActivityTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/InteractionActivityTest.kt @@ -46,7 +46,7 @@ internal class InteractionActivityTest { fun `real Compose and embedded Views capture through window with replay off`() { val events = CopyOnWriteArrayList() val config = - PostHogAndroidConfig("test").apply { + PostHogAndroidConfig("test", "http://127.0.0.1:1").apply { captureElementInteractions = true sessionReplay = false captureApplicationLifecycleEvents = false @@ -54,8 +54,6 @@ internal class InteractionActivityTest { captureDeepLinks = false capturePushNotificationOpened = false capturePushNotificationSubscriptions = false - @Suppress("DEPRECATION") // Test isolation: do not fetch remote configuration. - remoteConfig = false preloadFeatureFlags = false addBeforeSend { event -> events += event diff --git a/posthog-android/src/test/java/com/posthog/android/internal/InteractionDetectorActivityTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/InteractionDetectorActivityTest.kt index a380c5325..7091f75f4 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/InteractionDetectorActivityTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/InteractionDetectorActivityTest.kt @@ -53,7 +53,7 @@ internal class InteractionDetectorActivityTest { @Suppress("DEPRECATION") private fun setup() { val config = - PostHogAndroidConfig("test").apply { + PostHogAndroidConfig("test", "http://127.0.0.1:1").apply { captureRageClicks = true captureDeadClicks = true sessionReplay = false @@ -62,7 +62,6 @@ internal class InteractionDetectorActivityTest { captureDeepLinks = false capturePushNotificationOpened = false capturePushNotificationSubscriptions = false - remoteConfig = false preloadFeatureFlags = false addBeforeSend { event -> events += event diff --git a/posthog-android/src/test/java/com/posthog/android/internal/InteractionDetectorIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/InteractionDetectorIntegrationTest.kt index 90d2b7c89..730e1d45f 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/InteractionDetectorIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/InteractionDetectorIntegrationTest.kt @@ -48,12 +48,11 @@ internal class InteractionDetectorIntegrationTest { @Suppress("DEPRECATION") private val config = - PostHogAndroidConfig(PROJECT_TOKEN).apply { + PostHogAndroidConfig(PROJECT_TOKEN, "http://127.0.0.1:1").apply { captureRageClicks = true captureDeadClicks = true sessionReplay = false cachePreferences = PostHogMemoryPreferences() - remoteConfig = false preloadFeatureFlags = false addBeforeSend { event -> if (event.event in listOf("\$rageclick", "\$dead_click", "\$autocapture")) events += event @@ -253,9 +252,8 @@ internal class InteractionDetectorIntegrationTest { fun `secondary session changes use normal capture context when owner is not notified`() { val secondary = PostHog.with( - PostHogAndroidConfig("secondary-test-key").apply { + PostHogAndroidConfig("secondary-test-key", "http://127.0.0.1:1").apply { cachePreferences = PostHogMemoryPreferences() - remoteConfig = false preloadFeatureFlags = false addBeforeSend { null } }, diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index fcf0b0a72..a4571077a 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -2127,53 +2127,6 @@ internal class PostHogReplayIntegrationTest { } } - @Test - fun `gate does not arm when neither remoteConfig nor preloadFeatureFlags will fetch`() { - // With both remoteConfig and preloadFeatureFlags disabled, setup dispatches no /config or - // /flags request, so no onRemoteConfig callback arrives to disarm the gate. Arming it would - // buffer every snapshot for the whole session; instead it must stay disarmed and persist. - val remoteConfigMock = - mock { - on { isSessionReplayFlagActive() } doReturn true - on { hasRemoteConfigFetched() } doReturn false - on { makeSamplingDecision(any()) } doReturn true - on { getEventTriggers() } doReturn emptySet() - on { getRecordingMinimumDurationMs() } doReturn null - } - val config = - PostHogAndroidConfig(PROJECT_TOKEN).apply { - remoteConfigHolder = remoteConfigMock - sessionReplay = true - @Suppress("DEPRECATION") - remoteConfig = false - preloadFeatureFlags = false - } - val storagePrefix = tmpDir.newFolder().absolutePath - val executor = createReplayExecutor() - val innerQueue = - PostHogQueue( - config, - EndpointSpec.snapshot(config, PostHogApi(config), storagePrefix), - executor, - ) - val replayQueue = PostHogReplayQueue(config, innerQueue, storagePrefix, executor) - config.replayQueueHolder = replayQueue - val sut = PostHogReplayIntegration(mock(), config, MainHandler()) - val postHog = mock() - whenever(postHog.getSessionId()).thenReturn(UUID.randomUUID()) - sut.install(postHog) - sut.start(resumeCurrent = true) - try { - // No remote-config callback will ever fire, so buffering must not be armed. - replayQueue.add(createTestEvent("snapshot_1")) - awaitCondition { replayQueue.depth == 1 } - assertEquals(0, replayQueue.bufferDepth) - assertEquals(1, replayQueue.depth) - } finally { - sut.uninstall() - } - } - @Test fun `mid-session resume forces a fresh keyframe by clearing per-view snapshot state`() { // A mid-session flag-on resume must emit a fresh full snapshot: while stopped, per-view state @@ -2599,9 +2552,7 @@ internal class PostHogReplayIntegrationTest { @Test @Config(sdk = [26], shadows = [SnapshotBoundaryShadowPixelCopy::class]) fun `committed snapshot keeps its session when rotation precedes core enrichment`() { - val config = PostHogAndroidConfig(PROJECT_TOKEN) - @Suppress("DEPRECATION") - config.remoteConfig = false + val config = PostHogAndroidConfig(PROJECT_TOKEN, "http://127.0.0.1:1") config.preloadFeatureFlags = false var captured: PostHogEvent? = null config.addBeforeSend { event -> diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysRestartTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysRestartTest.kt index cb2c57ea5..16521bde8 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysRestartTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysRestartTest.kt @@ -139,7 +139,6 @@ internal class PostHogSurveysRestartTest { PostHogAndroidConfig(projectToken), ) preloadFeatureFlags = false - remoteConfig = false surveys = true surveysConfig.surveysDelegate = renderer surveysConfig.overrideDisplayLanguage = language 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 264e46bee..e0adad853 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -306,7 +306,6 @@ public open class PostHogConfig constructor( debug = debug, sendFeatureFlagEvent = sendFeatureFlagEvent, preloadFeatureFlags = preloadFeatureFlags, - remoteConfig = remoteConfig, flushAt = flushAt, maxQueueSize = maxQueueSize, maxBatchSize = maxBatchSize, 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 6d36783ea..b68787d37 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt @@ -175,7 +175,6 @@ internal class PostHogConfigTest { assertEquals(true, coreConfig.debug) assertEquals(false, coreConfig.sendFeatureFlagEvent) assertEquals(false, coreConfig.preloadFeatureFlags) - assertEquals(false, coreConfig.remoteConfig) assertEquals(15, coreConfig.flushAt) assertEquals(750, coreConfig.maxQueueSize) assertEquals(30, coreConfig.maxBatchSize) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 1d18f638e..d515cea20 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -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;ZZIIIIIILcom/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;ZZIIIIIILcom/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/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 final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V public final fun getBeforeSendList ()Ljava/util/List; @@ -196,7 +196,6 @@ public class com/posthog/PostHogConfig { public final fun getPushIdentityProvider ()Lkotlin/jvm/functions/Function3; public final fun getQueueProvider ()Lkotlin/jvm/functions/Function5; public final fun getReleaseIdentifier ()Ljava/lang/String; - public final fun getRemoteConfig ()Z public final fun getRemoteConfigHolder ()Lcom/posthog/internal/PostHogRemoteConfig; public final fun getRemoteConfigProvider ()Lkotlin/jvm/functions/Function6; public final fun getReplayStoragePrefix ()Ljava/lang/String; @@ -246,7 +245,6 @@ public class com/posthog/PostHogConfig { public final fun setProxy (Ljava/net/Proxy;)V public final fun setPushIdentityProvider (Lkotlin/jvm/functions/Function3;)V public final fun setReleaseIdentifier (Ljava/lang/String;)V - public final fun setRemoteConfig (Z)V public final fun setRemoteConfigHolder (Lcom/posthog/internal/PostHogRemoteConfig;)V public final fun setReplayStoragePrefix (Ljava/lang/String;)V public final fun setRequestHeaders (Ljava/util/Map;)V diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 6bd98ff1e..a616e878c 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -393,7 +393,6 @@ public class PostHog private constructor( } // only because of testing in isolation, this flag is always enabled - @Suppress("DEPRECATION") if (reloadFeatureFlags) { // 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. @@ -401,15 +400,10 @@ public class PostHog private constructor( notifyFeatureFlagsCallback(internalOnFeatureFlagsLoaded) notifyFeatureFlagsCallback(config.onFeatureFlags) } - when { - config.remoteConfig -> - loadRemoteConfigRequest( - internalOnFeatureFlagsLoaded, - config.onFeatureFlags, - ) - - config.preloadFeatureFlags -> reloadFeatureFlags(config.onFeatureFlags) - } + loadRemoteConfigRequest( + internalOnFeatureFlagsLoaded, + config.onFeatureFlags, + ) } // Reconcile a differing identified bootstrap after the setup reload is dispatched. diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index c01078500..c72ffa1e4 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -108,15 +108,6 @@ public open class PostHogConfig( * Default: true */ public var setDefaultPersonProperties: Boolean = true, - /** - * Preload PostHog remote config automatically - * Defaults to true - */ - @Deprecated( - message = "Remote config is now always enabled. This option is a no-op and will be removed in a future version.", - level = DeprecationLevel.WARNING, - ) - public var remoteConfig: Boolean = true, /** * Number of minimum events before they are sent over the wire * Defaults to 20 diff --git a/posthog/src/test/java/com/posthog/PostHogBeforeSendTest.kt b/posthog/src/test/java/com/posthog/PostHogBeforeSendTest.kt index 454282022..e4bbba3c1 100644 --- a/posthog/src/test/java/com/posthog/PostHogBeforeSendTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogBeforeSendTest.kt @@ -34,7 +34,6 @@ internal class PostHogBeforeSendTest { storagePrefix = tmpDir.newFolder().absolutePath replayStoragePrefix = tmpDir.newFolder().absolutePath preloadFeatureFlags = false - remoteConfig = false cachePreferences = PostHogMemoryPreferences() hooks.forEach { addBeforeSend(it) } } diff --git a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt index b4d4c80b1..214256e76 100644 --- a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt @@ -30,13 +30,13 @@ internal class PostHogBootstrapTest { private val serializer = PostHogSerializer(PostHogConfig(PROJECT_TOKEN)) private lateinit var config: PostHogConfig - @Suppress("DEPRECATION") private fun getSut( host: String = PostHogConfig.DEFAULT_HOST, bootstrap: PostHogBootstrapConfig? = null, cachePreferences: PostHogMemoryPreferences = PostHogMemoryPreferences(), preloadFeatureFlags: Boolean = false, - reloadFeatureFlags: Boolean = true, + // setup always requests /config; skip it when the test doesn't preload flags + reloadFeatureFlags: Boolean = preloadFeatureFlags, sendFeatureFlagEvent: Boolean = true, flushAt: Int = 1, storagePrefix: String = tmpDir.newFolder().absolutePath, @@ -52,8 +52,6 @@ internal class PostHogBootstrapTest { this.preloadFeatureFlags = preloadFeatureFlags this.sendFeatureFlagEvent = sendFeatureFlagEvent this.cachePreferences = cachePreferences - // keep setup on the single /flags path instead of the /config + /flags path - this.remoteConfig = false this.bootstrap = bootstrap this.optOut = optOut this.onFeatureFlags = onFeatureFlags @@ -370,6 +368,9 @@ internal class PostHogBootstrapTest { var loaded = false 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 }, ) diff --git a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt index 82575e966..15122e4fe 100644 --- a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt @@ -48,11 +48,11 @@ internal class PostHogFeatureFlagsTest { storagePrefix: String = tmpDir.newFolder().absolutePath, optOut: Boolean = false, preloadFeatureFlags: Boolean = true, - reloadFeatureFlags: Boolean = true, + // setup always requests /config; skip it when the test doesn't preload flags + reloadFeatureFlags: Boolean = preloadFeatureFlags, sendFeatureFlagEvent: Boolean = true, reuseAnonymousId: Boolean = false, integration: PostHogIntegration? = null, - remoteConfig: Boolean = false, cachePreferences: PostHogMemoryPreferences = PostHogMemoryPreferences(), beforeSend: PostHogBeforeSend? = null, evaluationContexts: List? = null, @@ -73,7 +73,6 @@ internal class PostHogFeatureFlagsTest { this.reuseAnonymousId = reuseAnonymousId this.cachePreferences = cachePreferences this.evaluationContexts = evaluationContexts - this.remoteConfig = remoteConfig if (beforeSend != null) { addBeforeSend(beforeSend) } diff --git a/posthog/src/test/java/com/posthog/PostHogIntegrationChangeTest.kt b/posthog/src/test/java/com/posthog/PostHogIntegrationChangeTest.kt index 041a7548e..258cc39ea 100644 --- a/posthog/src/test/java/com/posthog/PostHogIntegrationChangeTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogIntegrationChangeTest.kt @@ -26,9 +26,8 @@ internal class PostHogIntegrationChangeTest { @Suppress("DEPRECATION") private val client = PostHog.with( - PostHogConfig(PROJECT_TOKEN).apply { + PostHogConfig(PROJECT_TOKEN, "http://127.0.0.1:1").apply { cachePreferences = PostHogMemoryPreferences() - remoteConfig = false preloadFeatureFlags = false addBeforeSend { null } addIntegration(integration) diff --git a/posthog/src/test/java/com/posthog/PostHogIntegrationInstallFailureTest.kt b/posthog/src/test/java/com/posthog/PostHogIntegrationInstallFailureTest.kt index d5a3717b8..93dd6f259 100644 --- a/posthog/src/test/java/com/posthog/PostHogIntegrationInstallFailureTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogIntegrationInstallFailureTest.kt @@ -27,10 +27,9 @@ internal class PostHogIntegrationInstallFailureTest { @Suppress("DEPRECATION") client = PostHog.with( - PostHogConfig(PROJECT_TOKEN).apply { + PostHogConfig(PROJECT_TOKEN, "http://127.0.0.1:1").apply { logger = testLogger cachePreferences = PostHogMemoryPreferences() - remoteConfig = false preloadFeatureFlags = false addBeforeSend { null } addIntegration(broken) diff --git a/posthog/src/test/java/com/posthog/PostHogOkHttpInterceptorTest.kt b/posthog/src/test/java/com/posthog/PostHogOkHttpInterceptorTest.kt index 09467943a..d91a9b2c3 100644 --- a/posthog/src/test/java/com/posthog/PostHogOkHttpInterceptorTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogOkHttpInterceptorTest.kt @@ -194,7 +194,6 @@ internal class PostHogOkHttpInterceptorTest { this.replayStoragePrefix = tmpDir.newFolder().absolutePath this.cachePreferences = PostHogMemoryPreferences() this.preloadFeatureFlags = false - this.remoteConfig = false } val postHog = diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 5ce8ff3be..22176defb 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -90,11 +90,11 @@ internal class PostHogTest { optOut: Boolean = false, persistOptOut: Boolean = true, preloadFeatureFlags: Boolean = true, - reloadFeatureFlags: Boolean = true, + // setup always requests /config; skip it when the test doesn't preload flags + reloadFeatureFlags: Boolean = preloadFeatureFlags, sendFeatureFlagEvent: Boolean = true, reuseAnonymousId: Boolean = false, integration: PostHogIntegration? = null, - remoteConfig: Boolean = false, surveys: Boolean = false, cachePreferences: PostHogPreferences? = PostHogMemoryPreferences(), beforeSend: PostHogBeforeSend? = null, @@ -121,7 +121,6 @@ internal class PostHogTest { this.reuseAnonymousId = reuseAnonymousId this.cachePreferences = cachePreferences this.evaluationContexts = evaluationContexts - this.remoteConfig = remoteConfig this.surveys = surveys if (beforeSend != null) { addBeforeSend(beforeSend) @@ -368,22 +367,6 @@ internal class PostHogTest { sut.close() } - @Test - fun `preload feature flags if enabled`() { - val http = mockHttp() - val url = http.url("/") - - val sut = getSut(url.toString()) - - remoteConfigExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - assertEquals(1, http.requestCount) - assertEquals("/flags/?v=2", request.path) - - sut.close() - } - @Test fun `reload feature flags and call the callback`() { val http = mockHttp() @@ -409,7 +392,7 @@ internal class PostHogTest { val http = mockHttp() val url = http.url("/") - val sut = getSut(url.toString(), remoteConfig = true, preloadFeatureFlags = false) + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = true) remoteConfigExecutor.shutdownAndAwaitTermination() @@ -437,7 +420,7 @@ internal class PostHogTest { ) val url = http.url("/") - val sut = getSut(url.toString(), remoteConfig = true) + val sut = getSut(url.toString()) remoteConfigExecutor.shutdownAndAwaitTermination() @@ -469,7 +452,7 @@ internal class PostHogTest { ) val url = http.url("/") - val sut = getSut(url.toString(), remoteConfig = true) + val sut = getSut(url.toString()) remoteConfigExecutor.shutdownAndAwaitTermination() @@ -1665,7 +1648,7 @@ internal class PostHogTest { override fun onSurveysLoaded(surveys: List) {} } - val sut = getSut(url.toString(), remoteConfig = true, integration = surveysIntegration) + val sut = getSut(url.toString(), integration = surveysIntegration) http.takeRequest() http.takeRequest() @@ -1690,7 +1673,7 @@ internal class PostHogTest { val url = http.url("/") val integration = FakePostHogIntegration() - val sut = getSut(url.toString(), remoteConfig = true, integration = integration) + val sut = getSut(url.toString(), integration = integration) remoteConfigExecutor.shutdownAndAwaitTermination() assertEquals(1, integration.remoteConfigCount) @@ -3207,17 +3190,25 @@ internal class PostHogTest { @Test fun `reset reloads flags as anon user`() { - val http = mockHttp() + // setup's /config reports no flags, so the only /flags request comes from reset + val http = + mockHttp( + response = + MockResponse() + .setBody(File("src/test/resources/json/basic-remote-config-no-flags.json").readText()), + ) + http.enqueue(MockResponse().setBody(responseFlagsApi)) val url = http.url("/") - val sut = getSut(url.toString(), preloadFeatureFlags = false) + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = true) sut.reset() remoteConfigExecutor.shutdownAndAwaitTermination() + assertEquals("/array/${PROJECT_TOKEN}/config", http.takeRequest().path) val request = http.takeRequest() - assertEquals(1, http.requestCount) + assertEquals(2, http.requestCount) assertEquals("/flags/?v=2", request.path) sut.close() @@ -4465,8 +4456,9 @@ internal class PostHogTest { mockHttp( response = MockResponse() - .setBody(responseFlagsApi), + .setBody(File("src/test/resources/json/basic-remote-config.json").readText()), ) + http.enqueue(MockResponse().setBody(responseFlagsApi)) val url = http.url("/") val sut = getSut(url.toString(), preloadFeatureFlags = true, context = TestPostHogContext()) From b2361cd2da7b7722dd4121472d30f98aa4176169 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:38:37 -0400 Subject: [PATCH 05/11] chore!: pass the load result to the feature flags callback (#877) * 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. * 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. * fix: report persisted flags when the first flags load fails Co-Authored-By: Claude Opus 5.5 * fix: isolate the bootstrap internal flags callback from setup Co-Authored-By: Claude Opus 5.5 * fix: keep loaded flags when a flags reload fails Co-Authored-By: Claude Opus 5.5 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- .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 | 1 - .../com/posthog/server/PostHogConfigTest.kt | 2 +- posthog/api/posthog.api | 41 +-- posthog/src/main/java/com/posthog/PostHog.kt | 36 ++- .../main/java/com/posthog/PostHogConfig.kt | 5 +- .../posthog/PostHogFeatureFlagsCallback.kt | 27 ++ .../com/posthog/PostHogFeatureFlagsLoaded.kt | 47 ++++ .../main/java/com/posthog/PostHogInterface.kt | 17 +- .../posthog/internal/PostHogRemoteConfig.kt | 82 ++++-- .../java/com/posthog/PostHogBootstrapTest.kt | 11 +- .../com/posthog/PostHogFeatureFlagsTest.kt | 8 +- .../src/test/java/com/posthog/PostHogTest.kt | 5 +- .../internal/PostHogRemoteConfigTest.kt | 255 +++++++++++++++--- .../java/com/posthog/PostHogFake.kt | 2 +- 20 files changed, 446 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..ca5fb7278 --- /dev/null +++ b/.changeset/server-keep-on-feature-flags.md @@ -0,0 +1,5 @@ +--- +'posthog-server': minor +--- + +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..e0a484bed 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -311,7 +311,6 @@ public open class PostHogConfig constructor( maxBatchSize = maxBatchSize, flushIntervalSeconds = flushIntervalSeconds, encryption = encryption, - onFeatureFlags = onFeatureFlags, 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..f77668c76 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt @@ -180,7 +180,7 @@ internal class PostHogConfigTest { assertEquals(30, coreConfig.maxBatchSize) assertEquals(45, coreConfig.flushIntervalSeconds) assertEquals(mockEncryption, coreConfig.encryption) - assertEquals(mockOnFeatureFlags, coreConfig.onFeatureFlags) + assertNull(coreConfig.onFeatureFlags) } @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..63a8c0f65 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,12 @@ 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) + try { + internalOnFeatureFlagsLoaded() + } catch (e: Throwable) { + config.logger.log("Executing the internal feature flags callback failed: $e") + } + notifyFeatureFlagsCallback(config.onFeatureFlags, remoteConfig?.getFeatureFlags(), errorsLoading = false) } loadRemoteConfigRequest( internalOnFeatureFlagsLoaded, @@ -1915,18 +1919,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 +1944,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 +1960,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 +1974,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 +2713,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..2252f392a --- /dev/null +++ b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt @@ -0,0 +1,27 @@ +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). 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 + * 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..c1091864a 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 @@ -116,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 @@ -251,16 +256,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 -> { + { forEach { runSafely(it) } } + } + } + + private fun List.asSingleCallback(): PostHogFeatureFlagsCallback? = when (size) { 0 -> null 1 -> first() - else -> PostHogOnFeatureFlags { forEach { it.runSafely() } } + 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 +286,20 @@ 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 { + // a failed first load still reports the persisted flags, so hydrate them first + loadFeatureFlagsFromCacheIfNeeded() + callback.loaded(PostHogFeatureFlagsLoaded(getFeatureFlags() ?: emptyMap(), errorsLoading)) + } + } } // Notifies that a remote config resolution attempt finished. Callers set @@ -295,8 +317,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 +326,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = true, ) if (!remoteConfigHasFetched.get()) { notifyRemoteConfigResolved() @@ -382,6 +405,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 +417,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = true, ) if (!remoteConfigHasFetched.get()) { notifyRemoteConfigResolved() @@ -405,6 +431,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = true, ) config.logger.log("Loading remote config failed: $e") if (!remoteConfigHasFetched.get()) { @@ -831,8 +858,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 +867,7 @@ public class PostHogRemoteConfig( runOnFeatureFlagsCallbacks( internalOnFeatureFlags = internalOnFeatureFlags, onFeatureFlags = onFeatureFlags, + errorsLoading = true, ) if (notifyRemoteConfigLoaded && !remoteConfigHasFetched.get()) { notifyRemoteConfigResolved() @@ -879,6 +907,7 @@ public class PostHogRemoteConfig( return } + var errorsLoading = true try { val deviceId = config.cachePreferences?.getValue(DEVICE_ID) as? String @@ -920,6 +949,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 +1060,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 +1090,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 +1121,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( @@ -1162,6 +1195,13 @@ public class PostHogRemoteConfig( private fun loadFeatureFlagsFromCache() { config.cachePreferences?.let { preferences -> + // 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 + } + val generation = synchronized(featureFlagsLock) { featureFlagsGeneration } + @Suppress("UNCHECKED_CAST") val flags = preferences.getValue( @@ -1189,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 @@ -1253,7 +1296,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() } } @@ -1705,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/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..c242f2ecb 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,176 @@ 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 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) + } + + // 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 + val lockedReads = AtomicInteger() + + override fun getValue( + key: String, + defaultValue: Any?, + ): Any? { + if (!available) { + lockedReads.incrementAndGet() + return defaultValue + } + return delegate.getValue(key, 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 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)) }) + 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} + 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)) @@ -3075,8 +3246,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 +3285,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 e6894bf5e21dad640b58576a3bce42f8038586fa Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:15:50 -0400 Subject: [PATCH 06/11] feat: add an onFeatureFlags listener you can unsubscribe (#878) * feat: add an onFeatureFlags listener you can unsubscribe Registers a PostHogFeatureFlagsCallback that runs after every feature flags load, replays the current flags to late subscribers, and returns a PostHogFeatureFlagsSubscription, matching posthog-ios onFeatureFlags(_:). * fix: drop flag loads superseded by reset() or close() for listeners A load that started before reset() or close() could finish afterwards, reach listeners, and be replayed to late subscribers, including the previous user's flags after reset(). Each load now captures a generation that reset() and close() bump, and listener updates from an older generation are dropped. * fix: hydrate persisted flags before notifying onFeatureFlags listeners A failed first load built the listener snapshot before the flag cache was hydrated, so listeners and late replays got {} while the completion and getters returned the persisted flags. Hydrate before either callback runs. Co-Authored-By: Claude Opus 5.5 * fix: drop setup's flag load for listeners when the bootstrap callback closes the SDK Co-Authored-By: Claude Opus 5.5 * fix: deliver onFeatureFlags listeners on the main thread on Android Listeners usually update UI. On the remote config thread, a view update hits CalledFromWrongThreadException and LiveData.setValue throws, which the SDK catches, so the update is lost. This matches the on-feature-flags spec and posthog-ios. The config and reload callbacks keep running in the background. Co-Authored-By: Claude Opus 5.5 * fix: drop setup's flag load for listeners when an integration closes the SDK Capture the flags generation right after setup enables the SDK, before any user callout (integration install, session hooks, bootstrap config callback) that could close() it. Co-Authored-By: Claude Opus 5.5 * docs: scope onFeatureFlags threading notes by platform Co-Authored-By: Claude Opus 5.5 * fix: deliver bootstrapped flags to listeners before integrations install An integration that calls reloadFeatureFlags() during install queued its /flags request on the serial remote config executor ahead of setup's bootstrap notification, so onFeatureFlags listeners waited on the network even though bootstrapped flags were ready. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changeset/android-v4-major.md | 1 + .../com/posthog/android/PostHogAndroid.kt | 3 + .../com/posthog/android/PostHogAndroidTest.kt | 19 ++ posthog/api/posthog.api | 18 +- posthog/src/main/java/com/posthog/PostHog.kt | 133 +++++++++++-- .../main/java/com/posthog/PostHogConfig.kt | 8 + .../posthog/PostHogFeatureFlagsCallback.kt | 13 +- .../PostHogFeatureFlagsSubscription.kt | 30 +++ .../main/java/com/posthog/PostHogInterface.kt | 30 +++ .../posthog/internal/PostHogRemoteConfig.kt | 23 ++- .../java/com/posthog/PostHogBootstrapTest.kt | 108 ++++++++++ .../com/posthog/PostHogFeatureFlagsTest.kt | 185 ++++++++++++++++++ .../src/test/java/com/posthog/PostHogTest.kt | 6 +- .../internal/PostHogRemoteConfigTest.kt | 2 +- .../java/com/posthog/PostHogFake.kt | 2 + 15 files changed, 543 insertions(+), 38 deletions(-) create mode 100644 posthog/src/main/java/com/posthog/PostHogFeatureFlagsSubscription.kt diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index fc2077550..b24de8f7e 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -12,3 +12,4 @@ - **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` +- Add `onFeatureFlags()` to get notified after every feature flags load (on the main thread on Android), returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index c6e845b6e..18c407822 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -35,6 +35,7 @@ import com.posthog.internal.PostHogSessionManager import com.posthog.internal.logIntegrationFailure import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.io.File +import java.util.concurrent.Executor /** * Main entry point for the Android SDK. @@ -236,6 +237,8 @@ public class PostHogAndroid private constructor() { config.sampleRateProvider = { config.sessionReplayConfig.sampleRate } val mainHandler = MainHandler() + // onFeatureFlags listeners usually update UI, so deliver them on the main thread + config.featureFlagsListenerExecutor = Executor { mainHandler.handler.post(it) } config.addIntegrationSafely("PostHogReplayIntegration") { PostHogReplayIntegration(context, config, mainHandler) } diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt index 1f2e2de10..d92eae404 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt @@ -2,6 +2,7 @@ package com.posthog.android import android.content.Context import android.content.Intent +import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 import com.posthog.PostHog import com.posthog.android.errortracking.PostHogNativeCrashIntegration @@ -22,6 +23,7 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.robolectric.Shadows.shadowOf import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -145,6 +147,23 @@ internal class PostHogAndroidTest { assertTrue(config.context is PostHogAndroidContext) } + @Test + fun `runs feature flags listeners on the main thread`() { + val config = PostHogAndroidConfig(PROJECT_TOKEN) + + mockContextAppStart(context, tmpDir) + + PostHogAndroid.setup(context, config) + + var ranOn: Thread? = null + val worker = Thread { config.featureFlagsListenerExecutor!!.execute { ranOn = Thread.currentThread() } } + worker.start() + worker.join() + shadowOf(Looper.getMainLooper()).idle() + + assertSame(Looper.getMainLooper().thread, ranOn) + } + @Test fun `sets legacy storage path`() { val config = PostHogAndroidConfig(PROJECT_TOKEN) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index c949a9dbd..fdf89f65f 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -48,6 +48,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun isOptOut ()Z public fun isSessionActive ()Z public fun isSessionReplayActive ()Z + public fun onFeatureFlags (Lcom/posthog/PostHogFeatureFlagsCallback;)Lcom/posthog/PostHogFeatureFlagsSubscription; public fun optIn ()V public fun optOut ()V public fun register (Ljava/lang/String;Ljava/lang/Object;)V @@ -96,6 +97,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun isOptOut ()Z public fun isSessionActive ()Z public fun isSessionReplayActive ()Z + public fun onFeatureFlags (Lcom/posthog/PostHogFeatureFlagsCallback;)Lcom/posthog/PostHogFeatureFlagsSubscription; public fun optIn ()V public fun optOut ()V public final fun overrideSharedInstance (Lcom/posthog/PostHogInterface;)V @@ -172,6 +174,7 @@ public class com/posthog/PostHogConfig { public final fun getEvaluationContexts ()Ljava/util/List; public final fun getFeatureFlagCalledCacheSize ()I public final fun getFeatureFlagRequestMaxRetries ()I + public final fun getFeatureFlagsListenerExecutor ()Ljava/util/concurrent/Executor; public final fun getFlushAt ()I public final fun getFlushIntervalSeconds ()I public final fun getGetAnonymousId ()Lkotlin/jvm/functions/Function1; @@ -225,6 +228,7 @@ public class com/posthog/PostHogConfig { public final fun setEvaluationContexts (Ljava/util/List;)V public final fun setFeatureFlagCalledCacheSize (I)V public final fun setFeatureFlagRequestMaxRetries (I)V + public final fun setFeatureFlagsListenerExecutor (Ljava/util/concurrent/Executor;)V public final fun setFlushAt (I)V public final fun setFlushIntervalSeconds (I)V public final fun setGetAnonymousId (Lkotlin/jvm/functions/Function1;)V @@ -355,6 +359,11 @@ public final class com/posthog/PostHogFeatureFlagsLoaded { public final fun getVariants ()Ljava/util/Map; } +public final class com/posthog/PostHogFeatureFlagsSubscription { + public fun (Lkotlin/jvm/functions/Function0;)V + public final fun unsubscribe ()V +} + public abstract interface class com/posthog/PostHogIntegration { public fun install (Lcom/posthog/PostHogInterface;)V public fun onChange ()V @@ -405,6 +414,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public static synthetic fun isFeatureEnabled$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZLjava/lang/Boolean;ILjava/lang/Object;)Z public abstract fun isSessionActive ()Z public abstract fun isSessionReplayActive ()Z + public abstract fun onFeatureFlags (Lcom/posthog/PostHogFeatureFlagsCallback;)Lcom/posthog/PostHogFeatureFlagsSubscription; 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/PostHogFeatureFlagsCallback;)V @@ -1049,10 +1059,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;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 loadFeatureFlags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function1;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/Function1;Lcom/posthog/PostHogFeatureFlagsCallback;ILjava/lang/Object;)V + public final fun loadRemoteConfig (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function1;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/Function1;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 63a8c0f65..6134a2108 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -34,6 +34,7 @@ import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory import com.posthog.internal.errortracking.PostHogExceptionStepsBuffer +import com.posthog.internal.executeSafely import com.posthog.internal.logIntegrationFailure import com.posthog.internal.personPropertiesContext import com.posthog.internal.replay.PostHogSessionReplayHandler @@ -46,6 +47,7 @@ import com.posthog.surveys.Survey import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.util.Date import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -187,19 +189,90 @@ public class PostHog private constructor( private var isIdentifiedLoaded: Boolean = false private var isPersonProcessingLoaded: Boolean = false + // Public onFeatureFlags listeners. Owned by this instance rather than the remote config, so + // listeners registered before setup() (or across close()/setup()) keep receiving updates. + private val featureFlagsListeners = CopyOnWriteArrayList() + + // The last update delivered to listeners, replayed to late subscribers. Null until flags load, + // and cleared by reset() and close() so an update they superseded is dropped. + @Volatile + private var lastFeatureFlagsLoaded: PostHogFeatureFlagsLoaded? = null + + // Bumped by reset() and close(), so a flags load that started before them can't reach listeners. + @Volatile + private var featureFlagsGeneration = 0 + private val featureFlagsGenerationLock = Any() + + private class FeatureFlagsListener(val callback: PostHogFeatureFlagsCallback) { + @Volatile + var active = true + + // Only touched on the listener thread (see runFeatureFlagsListener), where every delivery and replay runs. + var lastDelivered: PostHogFeatureFlagsLoaded? = null + } + + // Runs on the remote config thread after every flags load, whether it succeeded or failed. + // Create it when the load starts, so listeners skip a load that reset() or close() superseded. + private fun internalOnFeatureFlagsLoaded(generation: Int = featureFlagsGeneration): (errorsLoading: Boolean) -> Unit { + return { errorsLoading -> + startSessionReplayOnFeatureFlagsLoaded() + notifyFeatureFlagsListeners(errorsLoading, generation) + } + } + + private fun clearFeatureFlagsLoaded() { + synchronized(featureFlagsGenerationLock) { + featureFlagsGeneration++ + lastFeatureFlagsLoaded = null + } + } + // this is called if the feature flags are loaded for the first time and recording isn't started yet - private val internalOnFeatureFlagsLoaded: () -> Unit = - { - // under setupLock so a concurrent optOut can't uninstall replay between the check and the start - synchronized(setupLock) { - sessionReplayHandler?.let { - if (isSessionReplayConfigEnabled()) { - // start will bail if session replay is already active anyway - startSessionReplayInternal(resumeCurrent = true) - } + private fun startSessionReplayOnFeatureFlagsLoaded() { + // under setupLock so a concurrent optOut can't uninstall replay between the check and the start + synchronized(setupLock) { + sessionReplayHandler?.let { + if (isSessionReplayConfigEnabled()) { + // start will bail if session replay is already active anyway + startSessionReplayInternal(resumeCurrent = true) } } } + } + + private fun notifyFeatureFlagsListeners( + errorsLoading: Boolean, + generation: Int, + ) { + val loaded = PostHogFeatureFlagsLoaded(remoteConfig?.getFeatureFlags() ?: emptyMap(), errorsLoading) + synchronized(featureFlagsGenerationLock) { + if (featureFlagsGeneration != generation) return + lastFeatureFlagsLoaded = loaded + } + featureFlagsListeners.forEach { listener -> runFeatureFlagsListener { deliverFeatureFlags(listener, loaded) } } + } + + // Called on the remote config thread. Hands the block to the platform's listener executor (the main + // thread on Android), or runs it right away when there's none. + private fun runFeatureFlagsListener(block: () -> Unit) { + val executor = config?.featureFlagsListenerExecutor ?: return block() + executor.executeSafely(block) + } + + private fun deliverFeatureFlags( + listener: FeatureFlagsListener, + loaded: PostHogFeatureFlagsLoaded, + ) { + // An earlier callback may have unsubscribed this one, or reset()/close() or a newer load + // may have superseded this update. A replay queued next to the same update is skipped too. + if (!listener.active || lastFeatureFlagsLoaded !== loaded || listener.lastDelivered === loaded) return + listener.lastDelivered = loaded + try { + listener.callback.loaded(loaded) + } catch (e: Throwable) { + config?.logger?.log("Executing the feature flags listener failed: $e") + } + } public override fun setup(config: T) { synchronized(setupLock) { @@ -357,6 +430,16 @@ public class PostHog private constructor( super.enabled = true + // Captured before any user callout below (integrations, session hooks, the bootstrap + // config callback), any of which may close() and so supersede setup's flags load. + val generation = featureFlagsGeneration + + // Queued before integrations install, so a reload one starts there can't hold the + // bootstrapped flags behind its network response. Listeners run on the remote config thread. + if (reloadFeatureFlags && remoteConfig?.hasBootstrapFlags() == true) { + remoteConfigExecutor.executeSafely { notifyFeatureFlagsListeners(errorsLoading = false, generation) } + } + // Initialize device_id if not already set. getDeviceId() handles lazy init // by seeding from the anonymous ID, providing a stable identifier for // device-level feature flag bucketing that survives identify() and reset(). @@ -398,14 +481,14 @@ public class PostHog private constructor( // callbacks now so listeners aren't blocked on the first network response. if (remoteConfig?.hasBootstrapFlags() == true) { try { - internalOnFeatureFlagsLoaded() + startSessionReplayOnFeatureFlagsLoaded() } catch (e: Throwable) { config.logger.log("Executing the internal feature flags callback failed: $e") } notifyFeatureFlagsCallback(config.onFeatureFlags, remoteConfig?.getFeatureFlags(), errorsLoading = false) } loadRemoteConfigRequest( - internalOnFeatureFlagsLoaded, + internalOnFeatureFlagsLoaded(generation), config.onFeatureFlags, ) } @@ -631,6 +714,7 @@ public class PostHog private constructor( pushSubscriptionManager = null featureFlagsCalled.clear() + clearFeatureFlagsLoaded() lastScreenName = null synchronized(replayDebugPropertiesLock) { lastReplayDebugPropertiesAt = null @@ -1938,13 +2022,30 @@ public class PostHog private constructor( return } loadFeatureFlagsRequest( - internalOnFeatureFlags = internalOnFeatureFlagsLoaded, + internalOnFeatureFlags = internalOnFeatureFlagsLoaded(), onFeatureFlags = onFeatureFlags, ) } + public override fun onFeatureFlags(callback: PostHogFeatureFlagsCallback): PostHogFeatureFlagsSubscription { + val listener = FeatureFlagsListener(callback) + featureFlagsListeners.add(listener) + + remoteConfigExecutor.executeSafely { + runFeatureFlagsListener { + // Read at delivery time so a delivery already queued can't be followed by an older value. + lastFeatureFlagsLoaded?.let { deliverFeatureFlags(listener, it) } + } + } + + return PostHogFeatureFlagsSubscription { + listener.active = false + featureFlagsListeners.remove(listener) + } + } + private fun loadFeatureFlagsRequest( - internalOnFeatureFlags: () -> Unit, + internalOnFeatureFlags: (errorsLoading: Boolean) -> Unit, onFeatureFlags: PostHogFeatureFlagsCallback? = null, ) { @Suppress("UNCHECKED_CAST") @@ -1974,7 +2075,7 @@ public class PostHog private constructor( } private fun loadRemoteConfigRequest( - internalOnFeatureFlags: (() -> Unit)?, + internalOnFeatureFlags: ((errorsLoading: Boolean) -> Unit)?, onFeatureFlags: PostHogFeatureFlagsCallback?, ) { @Suppress("UNCHECKED_CAST") @@ -2231,6 +2332,7 @@ public class PostHog private constructor( getPreferences().clear(except = except.toList()) } remoteConfig?.clear() + clearFeatureFlagsLoaded() featureFlagsCalled.clear() lastScreenName = null synchronized(cachedPersonPropertiesLock) { @@ -2717,6 +2819,9 @@ public class PostHog private constructor( shared.reloadFeatureFlags(onFeatureFlags) } + public override fun onFeatureFlags(callback: PostHogFeatureFlagsCallback): PostHogFeatureFlagsSubscription = + shared.onFeatureFlags(callback) + public override fun isFeatureEnabled( key: String, defaultValue: Boolean, diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index ee45ad3c4..f36d38c38 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -24,6 +24,7 @@ import com.posthog.surveys.PostHogSurveysConfig import okhttp3.OkHttpClient import java.net.Proxy import java.util.UUID +import java.util.concurrent.Executor import java.util.concurrent.ExecutorService /** @@ -490,6 +491,13 @@ public open class PostHogConfig( @PostHogInternal public var sampleRateProvider: (() -> Double?)? = null + /** + * Runs the [PostHog.onFeatureFlags] listeners. Set by platform-specific modules (e.g., Android) + * to deliver them on the main thread. When null, they run on the SDK's remote config thread. + */ + @PostHogInternal + public var featureFlagsListenerExecutor: Executor? = null + /** * Reference to the PostHogRemoteConfig instance, set during setup. * Used by integrations to check remote config values. diff --git a/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt index 2252f392a..355b9714f 100644 --- a/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt +++ b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsCallback.kt @@ -3,10 +3,15 @@ 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). 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. + * It's used by `PostHogConfig.onFeatureFlags`, `reloadFeatureFlags` and `onFeatureFlags`. + * + * For `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). 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. + * + * For `onFeatureFlags`, it runs on the main thread on Android, and on the SDK's background remote + * config thread on the JVM. * * Example: * ```kotlin diff --git a/posthog/src/main/java/com/posthog/PostHogFeatureFlagsSubscription.kt b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsSubscription.kt new file mode 100644 index 000000000..32aa85ec9 --- /dev/null +++ b/posthog/src/main/java/com/posthog/PostHogFeatureFlagsSubscription.kt @@ -0,0 +1,30 @@ +package com.posthog + +/** + * A registration returned by `onFeatureFlags`. + * + * The callback stays registered until you call [unsubscribe]. You don't need to keep a reference + * to this object unless you want to unsubscribe later. + * + * Example: + * ```kotlin + * val subscription = PostHog.onFeatureFlags { loaded -> + * // React to the new flags + * } + * + * // Later, stop listening + * subscription.unsubscribe() + * ``` + */ +public class PostHogFeatureFlagsSubscription + @PostHogInternal + public constructor( + private val onUnsubscribe: () -> Unit, + ) { + /** + * Stops the callback from being invoked. Calling it more than once has no effect. + */ + public fun unsubscribe() { + onUnsubscribe() + } + } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index e56fbf98b..cf0dd630f 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -141,6 +141,36 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun reloadFeatureFlags(onFeatureFlags: PostHogFeatureFlagsCallback? = null) + /** + * Registers a callback that's invoked whenever feature flags are loaded or updated. + * + * On Android, the callback runs on the main thread, after the getters already return the new + * values. On the JVM, it runs on the SDK's background remote config thread. It's + * invoked after the SDK loads flags at startup (including from bootstrap values), after + * [reloadFeatureFlags], and after calls that reload flags such as [identify], [group] or [reset]. + * If a load fails, [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the values are the last + * known flags. If flags have already loaded when you register, it's also invoked with the current + * values shortly afterwards. + * + * You can register before `setup()`, and the callback stays registered across `close()` and + * `setup()`. Treat each invocation as "flags may have changed", not as an exactly-once event. + * + * ```kotlin + * val subscription = PostHog.onFeatureFlags { loaded -> + * if (loaded.variants["new-checkout"] == "test") { + * // Show the test variant + * } + * } + * + * // Later, stop listening + * subscription.unsubscribe() + * ``` + * + * @param callback receives the enabled flags, their values, and whether loading failed + * @return a subscription; call [PostHogFeatureFlagsSubscription.unsubscribe] to stop receiving updates + */ + public fun onFeatureFlags(callback: PostHogFeatureFlagsCallback): PostHogFeatureFlagsSubscription + /** * Returns if a feature flag is enabled, the feature flag must be a Boolean * Docs https://posthog.com/docs/feature-flags and https://posthog.com/docs/experiments diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index c1091864a..f9bec33c9 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -64,7 +64,7 @@ public class PostHogRemoteConfig( val distinctId: String, val anonymousId: String?, val groups: Map?, - val internalOnFeatureFlags: List<() -> Unit>, + val internalOnFeatureFlags: List<(errorsLoading: Boolean) -> Unit>, val onFeatureFlags: List, ) @@ -257,13 +257,11 @@ public class PostHogRemoteConfig( } @JvmName("asSingleInternalCallback") - private fun List<() -> Unit>.asSingleCallback(): (() -> Unit)? = + private fun List<(errorsLoading: Boolean) -> Unit>.asSingleCallback(): ((errorsLoading: Boolean) -> Unit)? = when (size) { 0 -> null 1 -> first() - else -> { - { forEach { runSafely(it) } } - } + else -> { errorsLoading -> forEach { runSafely { it(errorsLoading) } } } } private fun List.asSingleCallback(): PostHogFeatureFlagsCallback? = @@ -286,17 +284,18 @@ public class PostHogRemoteConfig( } private fun runOnFeatureFlagsCallbacks( - internalOnFeatureFlags: (() -> Unit)?, + internalOnFeatureFlags: ((errorsLoading: Boolean) -> 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?.let { runSafely(it) } + if (internalOnFeatureFlags == null && onFeatureFlags == null) return + // a failed first load still reports the persisted flags, so hydrate them before either callback + runSafely { loadFeatureFlagsFromCacheIfNeeded() } + internalOnFeatureFlags?.let { runSafely { it(errorsLoading) } } onFeatureFlags?.let { callback -> runSafely { - // a failed first load still reports the persisted flags, so hydrate them first - loadFeatureFlagsFromCacheIfNeeded() callback.loaded(PostHogFeatureFlagsLoaded(getFeatureFlags() ?: emptyMap(), errorsLoading)) } } @@ -317,7 +316,7 @@ public class PostHogRemoteConfig( distinctId: String, anonymousId: String?, groups: Map?, - internalOnFeatureFlags: (() -> Unit)? = null, + internalOnFeatureFlags: ((errorsLoading: Boolean) -> Unit)? = null, onFeatureFlags: PostHogFeatureFlagsCallback? = null, ) { executor.executeSafely { @@ -858,7 +857,7 @@ public class PostHogRemoteConfig( distinctId: String, anonymousId: String?, groups: Map?, - internalOnFeatureFlags: (() -> Unit)?, + internalOnFeatureFlags: ((errorsLoading: Boolean) -> Unit)?, onFeatureFlags: PostHogFeatureFlagsCallback?, notifyRemoteConfigLoaded: Boolean = true, ) { @@ -1121,7 +1120,7 @@ public class PostHogRemoteConfig( distinctId: String, anonymousId: String?, groups: Map?, - internalOnFeatureFlags: (() -> Unit)? = null, + internalOnFeatureFlags: ((errorsLoading: Boolean) -> Unit)? = null, onFeatureFlags: PostHogFeatureFlagsCallback? = null, ) { executor.executeSafely { diff --git a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt index 19903d96a..8fb56afdd 100644 --- a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt @@ -7,16 +7,25 @@ import com.posthog.internal.PostHogPreferences.Companion.DISTINCT_ID import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogThreadFactory +import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest import org.junit.Rule import org.junit.rules.TemporaryFolder 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.AtomicBoolean import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue internal class PostHogBootstrapTest { @@ -382,6 +391,105 @@ internal class PostHogBootstrapTest { sut.close() } + @Test + fun `onFeatureFlags replays bootstrapped flags to a listener registered after setup`() { + val sut = + getSut( + host = "http://127.0.0.1:1", + // the replay comes from setup's flag loading, before /config answers + reloadFeatureFlags = true, + bootstrap = PostHogBootstrapConfig(featureFlags = mapOf("beta-ui" to true)), + ) + + val updates = LinkedBlockingQueue() + sut.onFeatureFlags { updates.add(it) } + + // setup's load to the unreachable host may already have failed, which keeps the bootstrapped flags + val loaded = updates.poll(5, TimeUnit.SECONDS)!! + assertEquals(mapOf("beta-ui" to true), loaded.variants) + + sut.close() + } + + @Test + fun `onFeatureFlags delivers bootstrapped flags before a reload an integration starts`() { + val release = CountDownLatch(1) + val http = MockWebServer() + http.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + if (request.path!!.contains("/flags")) { + release.await(10, TimeUnit.SECONDS) + return MockResponse().setBody("""{"featureFlags":{"beta-ui":false}}""") + } + return MockResponse().setBody("""{"hasFeatureFlags":true}""") + } + } + http.start() + val config = + PostHogConfig(PROJECT_TOKEN, http.url("/").toString()).apply { + storagePrefix = tmpDir.newFolder().absolutePath + cachePreferences = PostHogMemoryPreferences() + bootstrap = PostHogBootstrapConfig(featureFlags = mapOf("beta-ui" to true)) + addIntegration( + object : PostHogIntegration { + override fun install(postHog: PostHogInterface) { + postHog.reloadFeatureFlags() + } + }, + ) + } + val updates = LinkedBlockingQueue() + val subscription = PostHog.onFeatureFlags { updates.add(it) } + + try { + PostHog.setup(config) + + // the integration's /flags request is held, so this can only be the bootstrap delivery + val loaded = updates.poll(5, TimeUnit.SECONDS) + assertEquals(mapOf("beta-ui" to true), loaded?.variants) + } finally { + subscription.unsubscribe() + release.countDown() + PostHog.close() + http.shutdown() + } + } + + @Test + fun `closing from the bootstrap flags callback drops setup's load for listeners`() { + val http = mockHttp(total = 5, response = MockResponse().setBody("""{"hasFeatureFlags":false}""")) + val completed = LinkedBlockingQueue() + val first = AtomicBoolean(true) + val config = + PostHogConfig(PROJECT_TOKEN, http.url("/").toString()).apply { + storagePrefix = tmpDir.newFolder().absolutePath + cachePreferences = PostHogMemoryPreferences() + bootstrap = PostHogBootstrapConfig(featureFlags = mapOf("beta-ui" to true)) + onFeatureFlags = + PostHogFeatureFlagsCallback { + // the bootstrap delivery closes the SDK; setup's own load completes later + if (first.compareAndSet(true, false)) PostHog.close() else completed.add(it) + } + } + + try { + PostHog.setup(config) + + // setup's load notifies listeners just before this completion + assertNotNull(completed.poll(5, TimeUnit.SECONDS)) + + // close() cleared the snapshot, so only a load it superseded could refill it + val updates = LinkedBlockingQueue() + val subscription = PostHog.onFeatureFlags { updates.add(it) } + assertNull(updates.poll(500, TimeUnit.MILLISECONDS)) + subscription.unsubscribe() + } finally { + PostHog.close() + http.shutdown() + } + } + @Test fun `minimal flag called event strips bootstrap fields even though this SDK sets them`() { // fixture returns minimalFlagCalledEvents: true and IAmInactive with has_experiment: false; diff --git a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt index 399c7ee79..8c02709ce 100644 --- a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt @@ -9,11 +9,17 @@ import okhttp3.mockwebserver.MockResponse import org.junit.Rule import org.junit.rules.TemporaryFolder import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals +import kotlin.test.assertNull import kotlin.test.assertTrue internal class PostHogFeatureFlagsTest { @@ -57,6 +63,7 @@ internal class PostHogFeatureFlagsTest { beforeSend: PostHogBeforeSend? = null, evaluationContexts: List? = null, context: PostHogContext? = null, + featureFlagsListenerExecutor: Executor? = null, ): PostHogInterface { config = PostHogConfig(PROJECT_TOKEN, host).apply { @@ -78,6 +85,7 @@ internal class PostHogFeatureFlagsTest { } this.errorTrackingConfig.inAppIncludes.add("com.posthog") this.context = context + this.featureFlagsListenerExecutor = featureFlagsListenerExecutor } return PostHog.withInternal( config, @@ -401,4 +409,181 @@ internal class PostHogFeatureFlagsTest { assertTrue(result!!.flags.isEmpty()) assertTrue(result!!.variants.isEmpty()) } + + @Test + fun `onFeatureFlags listener receives each load until unsubscribed`() { + val flags = File("src/test/resources/json/basic-flags-with-non-active-flags.json").readText() + val http = mockHttp(response = MockResponse().setBody(flags)) + http.enqueue(MockResponse().setResponseCode(500)) + http.enqueue(MockResponse().setBody(flags)) + val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false) + val enabled = mapOf("4535-funnel-bar-viz" to true, "splashScreenName" to "SplashV2") + + val updates = LinkedBlockingQueue() + val subscription = sut.onFeatureFlags { updates.add(it) } + + sut.reloadFeatureFlags() + val loaded = updates.poll(5, TimeUnit.SECONDS)!! + assertEquals(enabled, loaded.variants) + assertFalse(loaded.errorsLoading) + + sut.reloadFeatureFlags() + val failed = updates.poll(5, TimeUnit.SECONDS)!! + assertEquals(enabled, failed.variants) + assertTrue(failed.errorsLoading) + + subscription.unsubscribe() + val reloaded = CountDownLatch(1) + sut.reloadFeatureFlags { reloaded.countDown() } + assertTrue(reloaded.await(5, TimeUnit.SECONDS)) + assertNull(updates.poll()) + } + + @Test + fun `onFeatureFlags reports and replays the persisted flags when the first load fails`() { + val persisted = mapOf("beta-ui" to true) + val cache = PostHogMemoryPreferences().apply { setValue("featureFlags", persisted) } + val http = mockHttp(response = MockResponse().setResponseCode(500)) + val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false, cachePreferences = cache) + + val updates = LinkedBlockingQueue() + sut.onFeatureFlags { updates.add(it) } + sut.reloadFeatureFlags() + + val failed = updates.poll(5, TimeUnit.SECONDS)!! + assertTrue(failed.errorsLoading) + assertEquals(persisted, failed.variants) + + val lateUpdates = LinkedBlockingQueue() + sut.onFeatureFlags { lateUpdates.add(it) } + assertEquals(persisted, lateUpdates.poll(5, TimeUnit.SECONDS)!!.variants) + } + + @Test + fun `onFeatureFlags replays loaded flags to a late listener`() { + val flags = File("src/test/resources/json/basic-flags-with-non-active-flags.json").readText() + val http = mockHttp(response = MockResponse().setBody(flags)) + val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false) + val reloaded = CountDownLatch(1) + sut.reloadFeatureFlags { reloaded.countDown() } + assertTrue(reloaded.await(5, TimeUnit.SECONDS)) + + val updates = LinkedBlockingQueue() + sut.onFeatureFlags { updates.add(it) } + + assertEquals(setOf("4535-funnel-bar-viz", "splashScreenName"), updates.poll(5, TimeUnit.SECONDS)!!.flags.toSet()) + } + + @Test + fun `onFeatureFlags listener survives close and setup`() { + val flags = File("src/test/resources/json/basic-flags-with-non-active-flags.json").readText() + val http = mockHttp(response = MockResponse().setBody(flags)) + val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false) + val updates = LinkedBlockingQueue() + sut.onFeatureFlags { updates.add(it) } + + sut.close() + sut.setup(config) + sut.reloadFeatureFlags() + + assertFalse(updates.poll(5, TimeUnit.SECONDS)!!.errorsLoading) + } + + @Test + fun `onFeatureFlags drops a load that finishes after close`() { + val flags = File("src/test/resources/json/basic-flags-with-non-active-flags.json").readText() + val http = mockHttp(response = MockResponse().setBody(flags).setHeadersDelay(500, TimeUnit.MILLISECONDS)) + val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false) + val updates = LinkedBlockingQueue() + sut.onFeatureFlags { updates.add(it) } + val reloaded = CountDownLatch(1) + sut.reloadFeatureFlags { reloaded.countDown() } + http.takeRequest(5, TimeUnit.SECONDS) + + sut.close() + assertTrue(reloaded.await(5, TimeUnit.SECONDS)) + sut.setup(config) + sut.onFeatureFlags { updates.add(it) } + + remoteConfigExecutor.submit {}.get(5, TimeUnit.SECONDS) + assertNull(updates.poll()) + } + + @Test + fun `closing from an integration's install drops setup's load for listeners`() { + val flags = File("src/test/resources/json/basic-flags-with-non-active-flags.json").readText() + val http = mockHttp(total = 2, response = MockResponse().setBody(flags)) + val updates = LinkedBlockingQueue() + val integration = + object : PostHogIntegration { + override fun install(postHog: PostHogInterface) { + postHog.onFeatureFlags { updates.add(it) } + postHog.close() + } + } + val sut = getSut(http.url("/").toString(), integration = integration) + + // setup's load runs on the remote config thread; close() cleared the snapshot it would refill + remoteConfigExecutor.submit {}.get(5, TimeUnit.SECONDS) + sut.onFeatureFlags { updates.add(it) } + remoteConfigExecutor.submit {}.get(5, TimeUnit.SECONDS) + assertNull(updates.poll()) + } + + @Test + fun `onFeatureFlags listener is not invoked by a reload while disabled`() { + val sut = getSut(mockHttp().url("/").toString(), preloadFeatureFlags = false) + val updates = LinkedBlockingQueue() + sut.onFeatureFlags { updates.add(it) } + sut.close() + + var result: PostHogFeatureFlagsLoaded? = null + sut.reloadFeatureFlags { result = it } + + remoteConfigExecutor.submit {}.get(5, TimeUnit.SECONDS) + assertTrue(result!!.errorsLoading) + assertNull(updates.poll()) + } + + @Test + fun `onFeatureFlags listeners run on the listener executor`() { + val flags = File("src/test/resources/json/basic-flags-with-non-active-flags.json").readText() + val http = mockHttp(response = MockResponse().setBody(flags)) + val listenerExecutor = Executors.newSingleThreadExecutor { Thread(it, "listener-thread") } + val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false, featureFlagsListenerExecutor = listenerExecutor) + + val threads = LinkedBlockingQueue() + sut.onFeatureFlags { threads.add(Thread.currentThread().name) } + sut.reloadFeatureFlags() + assertEquals("listener-thread", threads.poll(5, TimeUnit.SECONDS)) + + sut.onFeatureFlags { threads.add(Thread.currentThread().name) } + assertEquals("listener-thread", threads.poll(5, TimeUnit.SECONDS)) + listenerExecutor.shutdownAndAwaitTermination() + } + + @Test + fun `onFeatureFlags delivers a queued update once and drops it after unsubscribe`() { + val flags = File("src/test/resources/json/basic-flags-with-non-active-flags.json").readText() + val http = mockHttp(response = MockResponse().setBody(flags)) + val queued = LinkedBlockingQueue() + val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false, featureFlagsListenerExecutor = Executor { queued.add(it) }) + + val kept = LinkedBlockingQueue() + val dropped = LinkedBlockingQueue() + sut.onFeatureFlags { kept.add(it) } + val subscription = sut.onFeatureFlags { dropped.add(it) } + val reloaded = CountDownLatch(1) + sut.reloadFeatureFlags { reloaded.countDown() } + assertTrue(reloaded.await(5, TimeUnit.SECONDS)) + remoteConfigExecutor.submit {}.get(5, TimeUnit.SECONDS) + + // Each listener has its replay and the load's delivery queued, both carrying the loaded flags. + subscription.unsubscribe() + generateSequence { queued.poll() }.forEach { it.run() } + + assertEquals(1, kept.size) + assertFalse(kept.single().errorsLoading) + assertTrue(dropped.isEmpty()) + } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 0288881f1..ddb08ed2d 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -1500,9 +1500,9 @@ internal class PostHogTest { config.sessionReplay = true @Suppress("UNCHECKED_CAST") val onFlagsLoaded = - PostHog::class.java.getDeclaredField("internalOnFeatureFlagsLoaded") + PostHog::class.java.getDeclaredMethod("internalOnFeatureFlagsLoaded", Int::class.javaPrimitiveType) .apply { isAccessible = true } - .get(sut) as () -> Unit + .invoke(sut, 0) as (Boolean) -> Unit // optOut runs while the flags-loaded callback is between reading the handler and starting it var optOut: Thread? = null @@ -1516,7 +1516,7 @@ internal class PostHogTest { } } } - onFlagsLoaded() + onFlagsLoaded(false) 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 c242f2ecb..34fcdcd62 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -924,7 +924,7 @@ internal class PostHogRemoteConfigTest { called = true } - val internalOnFeatureFlags = { + val internalOnFeatureFlags = { _: Boolean -> calledInternal = true } diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index ca61394fe..dd5f42273 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -82,6 +82,8 @@ public class PostHogFake : PostHogInterface { override fun reloadFeatureFlags(onFeatureFlags: PostHogFeatureFlagsCallback?) { } + override fun onFeatureFlags(callback: PostHogFeatureFlagsCallback): PostHogFeatureFlagsSubscription = PostHogFeatureFlagsSubscription {} + override fun isFeatureEnabled( key: String, defaultValue: Boolean, From 0adff2a91521658a970cc01dc9d95be728acbfb9 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 13:31:12 -0400 Subject: [PATCH 07/11] chore: remove changeset for already released beforeSend fix The fix shipped from main in #885, so keeping the changeset here would release the same entry a second time. Co-Authored-By: Claude Opus 5.5 --- .changeset/before-send-once.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .changeset/before-send-once.md diff --git a/.changeset/before-send-once.md b/.changeset/before-send-once.md deleted file mode 100644 index a64308f68..000000000 --- a/.changeset/before-send-once.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'posthog': patch -'posthog-android': patch ---- - -Fix `beforeSend` running twice for each captured event, which discarded the hook's changes to `distinctId`, `timestamp` and `uuid` and compounded random sampling From 0e7e28a12862325bd3faf53a098ae90be8a31367 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 18:51:37 -0400 Subject: [PATCH 08/11] docs: consolidate 4.0 changeset and migration guide --- .changeset/android-v4-major.md | 9 +- .changeset/server-keep-on-feature-flags.md | 3 +- README.md | 1 + docs/migration-3.x-to-4.0.md | 251 +++++++++++++++++++++ 4 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 docs/migration-3.x-to-4.0.md diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index b24de8f7e..163e47135 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -1,15 +1,18 @@ --- 'posthog': major 'posthog-android': major +'posthog-android-surveys-compose': minor --- - **Breaking:** remove the deprecated `apiKey` property and the `PostHogConfig(apiKey = …)` and `PostHogAndroidConfig(apiKey = …)` factories; use `projectToken` instead - **Breaking:** remove the deprecated `propertiesSanitizer` config option and the `PostHogPropertiesSanitizer` interface; use `addBeforeSend` instead - **Breaking:** remove the deprecated `evaluationEnvironments` config option; use `evaluationContexts` instead -- **Breaking:** remove the deprecated `getFeatureFlagPayload()`; use `getFeatureFlagResult(key)?.payload` instead +- **Breaking:** remove the deprecated `getFeatureFlagPayload()`; use `getFeatureFlagResult(key, sendFeatureFlagEvent = false)?.payload` instead to keep not sending `$feature_flag_called` - **Breaking:** remove the deprecated `PostHogSessionReplayConfig.debouncerDelayMs`; use `throttleDelayMs` instead -- **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:** return `null` instead of the raw string for a malformed, empty, whitespace-only or lenient-only (unquoted or single-quoted) 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`, `com.posthog.android.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` +- **Breaking:** add the abstract `onFeatureFlags()` method to `PostHogInterface`; classes that implement it must add the method - Add `onFeatureFlags()` to get notified after every feature flags load (on the main thread on Android), returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` +- See the [3.x to 4.0 migration guide](https://github.com/PostHog/posthog-android/blob/main/docs/migration-3.x-to-4.0.md) for upgrade steps diff --git a/.changeset/server-keep-on-feature-flags.md b/.changeset/server-keep-on-feature-flags.md index ca5fb7278..c39f151bb 100644 --- a/.changeset/server-keep-on-feature-flags.md +++ b/.changeset/server-keep-on-feature-flags.md @@ -2,4 +2,5 @@ 'posthog-server': minor --- -Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged +- Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged +- Bump the `posthog` core dependency to 7.0 diff --git a/README.md b/README.md index 82960687e..cdcbfa1eb 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ SDK usage examples and code snippets live in the official documentation so they - [Android library docs](https://posthog.com/docs/libraries/android) - [Java/JVM library docs](https://posthog.com/docs/libraries/java) +- [Migrating from 3.x to 4.0](docs/migration-3.x-to-4.0.md) ## Required dependencies diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md new file mode 100644 index 000000000..8b8acbb4c --- /dev/null +++ b/docs/migration-3.x-to-4.0.md @@ -0,0 +1,251 @@ +# Migrating from PostHog Android 3.x to 4.0 + +4.0 removes APIs deprecated in 3.x and changes the feature flags callback. +Most changes are compile errors with a direct replacement. Read [Behavior changes](#behavior-changes) carefully: those won't show up at build time. + +For the full list of changes, see the [changelog](../posthog-android/CHANGELOG.md). + +## Requirements and versions + +| Artifact | Upgrade | +| --- | --- | +| `com.posthog:posthog-android` | 3.x to 4.0 | +| `com.posthog:posthog` (core) | 6.x to 7.0 | +| `com.posthog:posthog-android-surveys-compose` | the release that depends on `posthog-android` 4.0 | +| `com.posthog:posthog-server` | see [`posthog-server`](#posthog-server) | + +These are unchanged, so you don't need to change your build for them: + +- Android minimum API level: 23 +- Java bytecode target: Java 8 +- Kotlin language and API compatibility: 2.0 +- `posthog-android-gradle-plugin` + +Upgrade PostHog artifacts together. Core 7.0 is not binary compatible with SDK versions built against core 6.x, and a mismatch fails at runtime with errors such as `NoSuchMethodError` or `NoClassDefFoundError`. + +## Removed APIs + +All of these are compile errors. Calling them from an already-compiled library that you haven't updated fails at runtime. + +| Removed | Use instead | +| --- | --- | +| `PostHogConfig.apiKey`, `PostHogConfig(apiKey = …)`, `PostHogAndroidConfig(apiKey = …)` | `projectToken` | +| `PostHogConfig.propertiesSanitizer`, `PostHogPropertiesSanitizer` | `addBeforeSend` | +| `PostHogConfig.evaluationEnvironments` | `evaluationContexts` | +| `getFeatureFlagPayload()`, on the instance and on `PostHog` | `getFeatureFlagResult()` | +| `PostHogSessionReplayConfig.debouncerDelayMs` | `throttleDelayMs` | +| `PostHogConfig.remoteConfig` | nothing; remote config always loads | +| `PostHogOnFeatureFlags` | `PostHogFeatureFlagsCallback` | + +The constructor parameters of `PostHogConfig` and `PostHogSessionReplayConfig` move up where these were removed. If you pass arguments by position, check them, or switch to named arguments. + +### `apiKey` + +```kotlin +// 3.x +val config = PostHogAndroidConfig(apiKey = "phc_your_project_token") +val token = config.apiKey + +// 4.0 +val config = PostHogAndroidConfig(projectToken = "phc_your_project_token") +val token = config.projectToken +``` + +`PostHogAndroidConfig("phc_your_project_token")`, with no parameter name, still works. From Java, replace `getApiKey()` with `getProjectToken()`. + +### `propertiesSanitizer` + +```kotlin +// 3.x +config.propertiesSanitizer = PostHogPropertiesSanitizer { properties -> + properties.apply { remove("email") } +} + +// 4.0 +config.addBeforeSend { event -> + event.properties?.remove("email") + event +} +``` + +`addBeforeSend` gets the whole `PostHogEvent`. Return `null` to drop the event. If the hook throws, the event is dropped. + +### `evaluationEnvironments` + +```kotlin +// 3.x +config.evaluationEnvironments = listOf("production") + +// 4.0 +config.evaluationContexts = listOf("production") +``` + +### `getFeatureFlagPayload()` + +`getFeatureFlagPayload()` never sent `$feature_flag_called`. `getFeatureFlagResult()` sends it by default, following `sendFeatureFlagEvent`, so pass `sendFeatureFlagEvent = false` to keep your event volume and experiment exposures unchanged. If you drop that argument, nothing fails, but you'll send new events. + +The new call has no `defaultValue` parameter, so apply your default with `?:`. + +```kotlin +// 3.x +val payload = PostHog.getFeatureFlagPayload("checkout", defaultValue = "fallback") + +// 4.0 +val payload = PostHog.getFeatureFlagResult("checkout", sendFeatureFlagEvent = false)?.payload ?: "fallback" +``` + +### `debouncerDelayMs` + +```kotlin +// 3.x +PostHogSessionReplayConfig(debouncerDelayMs = 500) + +// 4.0 +PostHogSessionReplayConfig(throttleDelayMs = 500) +``` + +### `remoteConfig` + +Delete the option. Apps that left it at its default of `true` see no behavior change. + +```kotlin +// 3.x +config.remoteConfig = false + +// 4.0: remove the line +``` + +If you set it to `false`, your app's behavior changes: + +- The SDK now requests `/config` at setup, even if `preloadFeatureFlags` is also `false`, when 3.x sent no request at all. +- The settings in `/config` now apply, including the session replay, surveys, error tracking and push settings from your project. +- With `preloadFeatureFlags = true`, the SDK requests `/flags` only if `/config` reports that the project has feature flags. In 3.x it always requested `/flags`. If the project has none, the cached flags are cleared. + +## Feature flags callback + +`PostHogConfig.onFeatureFlags` and `reloadFeatureFlags()` now take a `PostHogFeatureFlagsCallback`, which receives a `PostHogFeatureFlagsLoaded`: + +- `flags`: the keys of the enabled flags +- `variants`: their values, either `true` or the variant string +- `errorsLoading`: whether the latest load failed. The values are then the last known ones, which can be empty + +```kotlin +// 3.x +config.onFeatureFlags = PostHogOnFeatureFlags { + refreshUi() +} + +// 4.0 +config.onFeatureFlags = PostHogFeatureFlagsCallback { loaded -> + if (!loaded.errorsLoading) { + refreshUi() + } +} +``` + +```java +// 3.x +PostHog.Companion.reloadFeatureFlags(() -> refreshUi()); + +// 4.0 +PostHog.Companion.reloadFeatureFlags(loaded -> refreshUi()); +``` + +A Kotlin trailing lambda such as `PostHog.reloadFeatureFlags { refreshUi() }` compiles unchanged, so it won't show up as an error. Check whether it should now read `errorsLoading`: the callback has always also run after a failed load. + +`errorsLoading` is `false` when PostHog answers that the project is over its feature flags quota, and the values are the cached flags. It's `true` when no request was made, such as when the device is offline or the distinct ID is blank, and when the SDK is disabled or isn't set up, with no values in that last case. + +These callbacks still run on a background thread, or right away on the calling thread when no load is attempted, so switch to the main thread before touching UI. With bootstrapped flags, `PostHogConfig.onFeatureFlags` also runs once on the thread calling `setup()`. + +## `PostHogInterface` implementations + +`PostHogInterface` lost `getFeatureFlagPayload()`, gained the abstract `onFeatureFlags()` and changed the callback type of `reloadFeatureFlags()`. Classes that implement it, such as hand-written fakes for tests, no longer compile until they're updated. + +```kotlin +class FakePostHog : PostHogInterface { + override fun reloadFeatureFlags(onFeatureFlags: PostHogFeatureFlagsCallback?) {} + + override fun onFeatureFlags(callback: PostHogFeatureFlagsCallback): PostHogFeatureFlagsSubscription = + TODO("Return a subscription from your own fake") + + // ... +} +``` + +`PostHogFeatureFlagsSubscription` and `PostHogFeatureFlagsLoaded` have constructors marked `@PostHogInternal`, which aren't intended for app code, so prefer a mocking library for these types if you can. + +## Internal APIs + +These helpers were public only by accident and were marked `@PostHogInternal`. They're no longer visible outside the SDK and have no replacement. Apps that don't use `@PostHogInternal` APIs aren't affected. + +- `com.posthog.internal`: `CustomHeadersInterceptor`, `PostHogPrintLogger`, `PropertyGroupDeserializer`, `PropertyValueDeserializer`, `personPropertiesContext()`, `PostHogPreferences.PUSH_SUBSCRIPTION_REJECTED` +- `com.posthog.internal.errortracking`: `ThrowableCoercer.fromThrowableToPostHogProperties()` +- `com.posthog.internal.replay`: `RRDocumentNode`, `RRDomContentLoadedEvent`, `RRLoadedEvent` +- `com.posthog.internal.surveys`: `findBestTranslationMatch()` +- `com.posthog.android.internal`: `Bitmap.isValid()`, `Bitmap.webpBase64()` +- `com.posthog.android.replay.internal`: `LogLine`, `LogcatParser` + +If you call `PostHogRemoteConfig.loadFeatureFlags()` or `loadRemoteConfig()`, their callback parameters changed too. The internal callback is now `(errorsLoading: Boolean) -> Unit`, and the other is a `PostHogFeatureFlagsCallback`. + +## Behavior changes + +### Feature flag payloads must be valid JSON + +A payload that isn't valid JSON now reads as `null` from `getFeatureFlagResult(key)?.payload` and `getAllFeatureFlags()`, instead of the raw string. With `debug` on, the SDK logs the flag key. + +| Payload | 3.x | 4.0 | +| --- | --- | --- | +| Malformed (`{broken`, `[1, 2`), empty or whitespace-only | the raw string | `null` | +| Only valid in lenient mode (unquoted `hello`, single quotes, comments) | decoded leniently | `null` | +| JSON `null` | the string `"null"` | `null` | +| Valid JSON (`"hello"`, `""`, `false`, `0`, objects, arrays) | decoded | decoded, unchanged | +| Bootstrapped payloads | as passed | as passed, unchanged | + +The flag's value, enabled state, variant and any other flag's payload aren't affected. + +If you read a payload as plain text, such as a payload saved as `hello` rather than `"hello"`, update it in PostHog to valid JSON before you upgrade, or your code gets `null`. + +A malformed payload cached by 3.x can still read as the raw string after the upgrade, until the app's first `/flags` response replaces it. + +### Other changes that don't fail the build + +The code you end up with after these two migrations compiles, so they're easy to miss: + +- [`getFeatureFlagResult()` sends `$feature_flag_called` by default](#getfeatureflagpayload), unlike `getFeatureFlagPayload()`. +- [Apps that set `remoteConfig = false` now load `/config`](#remoteconfig) and apply its settings. + +## New: `onFeatureFlags()` + +If you need flag updates outside of `PostHogConfig`, for example from a screen or a library, register a listener you can remove: + +```kotlin +val subscription = PostHog.onFeatureFlags { loaded -> + if (loaded.variants["new-checkout"] == "test") { + showTestVariant() + } +} + +// later +subscription.unsubscribe() +``` + +On Android, listeners run on the main thread. On the JVM, they run on a background thread. If flags have already loaded, a new listener also gets the current values shortly after it registers. You can register before `setup()`, and listeners stay registered across `close()` and `setup()`. + +## `posthog-server` + +`posthog-server` isn't having a major release, and its API is unchanged: it keeps its own `apiKey`, `getFeatureFlagPayload` and `remoteConfig`, and `com.posthog.PostHogOnFeatureFlags` now ships in `posthog-server` itself. None of the changes above apply to its API. + +Its next release depends on core 7.0, so update it if your project also uses core 7.0 or `posthog-android` 4.0. Older `posthog-server` versions aren't compatible with core 7.0 at runtime. + +## Checklist + +- [ ] Update `posthog-android` to 4.0, and `posthog-android-surveys-compose` and `posthog-server` if you use them. +- [ ] Replace `apiKey` with `projectToken`. +- [ ] Replace `propertiesSanitizer` with `addBeforeSend`. +- [ ] Replace `evaluationEnvironments` with `evaluationContexts`. +- [ ] Replace `getFeatureFlagPayload()` with `getFeatureFlagResult(key, sendFeatureFlagEvent = false)?.payload`. +- [ ] Replace `debouncerDelayMs` with `throttleDelayMs`. +- [ ] Remove `remoteConfig`. If it was `false`, expect `/config` requests and its settings to apply. +- [ ] Replace `PostHogOnFeatureFlags` with `PostHogFeatureFlagsCallback`, and check existing callbacks for `errorsLoading`. +- [ ] Update any class that implements `PostHogInterface`. +- [ ] Make sure every feature flag payload you read is valid JSON. From 4940963b962ff8f5938f6a05a95f077a09bc67cd Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 21:44:15 -0400 Subject: [PATCH 09/11] docs: merge the server changeset and correct the Android min API --- .changeset/android-v4-major.md | 5 +++++ .changeset/server-keep-on-feature-flags.md | 6 ------ README.md | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 .changeset/server-keep-on-feature-flags.md diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 163e47135..d5c612515 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -2,8 +2,11 @@ 'posthog': major 'posthog-android': major 'posthog-android-surveys-compose': minor +'posthog-server': minor --- +Entries apply to `posthog`, `posthog-android` and `posthog-android-surveys-compose`, which re-exports them. Only entries prefixed `posthog-server:` apply to `posthog-server`, which keeps its own `apiKey`, `getFeatureFlagPayload` and `remoteConfig`. + - **Breaking:** remove the deprecated `apiKey` property and the `PostHogConfig(apiKey = …)` and `PostHogAndroidConfig(apiKey = …)` factories; use `projectToken` instead - **Breaking:** remove the deprecated `propertiesSanitizer` config option and the `PostHogPropertiesSanitizer` interface; use `addBeforeSend` instead - **Breaking:** remove the deprecated `evaluationEnvironments` config option; use `evaluationContexts` instead @@ -16,3 +19,5 @@ - **Breaking:** add the abstract `onFeatureFlags()` method to `PostHogInterface`; classes that implement it must add the method - Add `onFeatureFlags()` to get notified after every feature flags load (on the main thread on Android), returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` - See the [3.x to 4.0 migration guide](https://github.com/PostHog/posthog-android/blob/main/docs/migration-3.x-to-4.0.md) for upgrade steps +- `posthog-server`: Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged +- `posthog-server`: Bump the `posthog` core dependency to 7.0 diff --git a/.changeset/server-keep-on-feature-flags.md b/.changeset/server-keep-on-feature-flags.md deleted file mode 100644 index c39f151bb..000000000 --- a/.changeset/server-keep-on-feature-flags.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'posthog-server': minor ---- - -- Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged -- Bump the `posthog` core dependency to 7.0 diff --git a/README.md b/README.md index cdcbfa1eb..50f723040 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ | Packages | Maven Central | Min Version | |-----------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| posthog-android | [![Maven Central](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog-android/badge.svg)](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog-android) | Android API 21 | +| posthog-android | [![Maven Central](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog-android/badge.svg)](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog-android) | Android API 23 | | posthog-android-surveys-compose | [![Maven Central](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog-android-surveys-compose/badge.svg)](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog-android-surveys-compose) | Android API 23 · `0.x` | | posthog (core) | [![Maven Central](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog/badge.svg)](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog) | Java 8 | | posthog-server | [![Maven Central](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog-server/badge.svg)](https://maven-badges.herokuapp.com/sonatype-central/com.posthog/posthog-server) | Java 8 | From 81340f3d9ef5a8cfd66c9adaaec38b4c4960b218 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 22:02:50 -0400 Subject: [PATCH 10/11] docs: keep the server changeset separate from Android --- .changeset/android-v4-major.md | 5 ----- .changeset/server-keep-on-feature-flags.md | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 .changeset/server-keep-on-feature-flags.md diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index d5c612515..163e47135 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -2,11 +2,8 @@ 'posthog': major 'posthog-android': major 'posthog-android-surveys-compose': minor -'posthog-server': minor --- -Entries apply to `posthog`, `posthog-android` and `posthog-android-surveys-compose`, which re-exports them. Only entries prefixed `posthog-server:` apply to `posthog-server`, which keeps its own `apiKey`, `getFeatureFlagPayload` and `remoteConfig`. - - **Breaking:** remove the deprecated `apiKey` property and the `PostHogConfig(apiKey = …)` and `PostHogAndroidConfig(apiKey = …)` factories; use `projectToken` instead - **Breaking:** remove the deprecated `propertiesSanitizer` config option and the `PostHogPropertiesSanitizer` interface; use `addBeforeSend` instead - **Breaking:** remove the deprecated `evaluationEnvironments` config option; use `evaluationContexts` instead @@ -19,5 +16,3 @@ Entries apply to `posthog`, `posthog-android` and `posthog-android-surveys-compo - **Breaking:** add the abstract `onFeatureFlags()` method to `PostHogInterface`; classes that implement it must add the method - Add `onFeatureFlags()` to get notified after every feature flags load (on the main thread on Android), returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` - See the [3.x to 4.0 migration guide](https://github.com/PostHog/posthog-android/blob/main/docs/migration-3.x-to-4.0.md) for upgrade steps -- `posthog-server`: Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged -- `posthog-server`: Bump the `posthog` core dependency to 7.0 diff --git a/.changeset/server-keep-on-feature-flags.md b/.changeset/server-keep-on-feature-flags.md new file mode 100644 index 000000000..c39f151bb --- /dev/null +++ b/.changeset/server-keep-on-feature-flags.md @@ -0,0 +1,6 @@ +--- +'posthog-server': minor +--- + +- Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged +- Bump the `posthog` core dependency to 7.0 From 7e23b1e31ab57d28b1507d6e03683cf5ece2015c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 22:11:31 -0400 Subject: [PATCH 11/11] docs: leave surveys-compose out of the 4.0 changeset --- .changeset/android-v4-major.md | 1 - docs/migration-3.x-to-4.0.md | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 163e47135..104bc40b3 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -1,7 +1,6 @@ --- 'posthog': major 'posthog-android': major -'posthog-android-surveys-compose': minor --- - **Breaking:** remove the deprecated `apiKey` property and the `PostHogConfig(apiKey = …)` and `PostHogAndroidConfig(apiKey = …)` factories; use `projectToken` instead diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 8b8acbb4c..a6423911a 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -11,7 +11,7 @@ For the full list of changes, see the [changelog](../posthog-android/CHANGELOG.m | --- | --- | | `com.posthog:posthog-android` | 3.x to 4.0 | | `com.posthog:posthog` (core) | 6.x to 7.0 | -| `com.posthog:posthog-android-surveys-compose` | the release that depends on `posthog-android` 4.0 | +| `com.posthog:posthog-android-surveys-compose` | keep your version, and declare `com.posthog:posthog-android:4.+` in your build so Gradle resolves 4.0 instead of the 3.x version it pulls in | | `com.posthog:posthog-server` | see [`posthog-server`](#posthog-server) | These are unchanged, so you don't need to change your build for them: @@ -239,7 +239,7 @@ Its next release depends on core 7.0, so update it if your project also uses cor ## Checklist -- [ ] Update `posthog-android` to 4.0, and `posthog-android-surveys-compose` and `posthog-server` if you use them. +- [ ] Update `posthog-android` to 4.0 and declare it in your build if you only depend on `posthog-android-surveys-compose`, and update `posthog-server` if you use it. - [ ] Replace `apiKey` with `projectToken`. - [ ] Replace `propertiesSanitizer` with `addBeforeSend`. - [ ] Replace `evaluationEnvironments` with `evaluationContexts`.