Skip to content
Merged
1 change: 1 addition & 0 deletions .changeset/android-v4-major.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 14 additions & 4 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <init> (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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
133 changes: 119 additions & 14 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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<FeatureFlagsListener>()

// 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)
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
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 <T : PostHogConfig> setup(config: T) {
synchronized(setupLock) {
Expand Down Expand Up @@ -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().
Expand Down Expand Up @@ -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,
)
}
Expand Down Expand Up @@ -631,6 +714,7 @@ public class PostHog private constructor(
pushSubscriptionManager = null

featureFlagsCalled.clear()
clearFeatureFlagsLoaded()
lastScreenName = null
synchronized(replayDebugPropertiesLock) {
lastReplayDebugPropertiesAt = null
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1974,7 +2075,7 @@ public class PostHog private constructor(
}

private fun loadRemoteConfigRequest(
internalOnFeatureFlags: (() -> Unit)?,
internalOnFeatureFlags: ((errorsLoading: Boolean) -> Unit)?,
onFeatureFlags: PostHogFeatureFlagsCallback?,
) {
@Suppress("UNCHECKED_CAST")
Expand Down Expand Up @@ -2231,6 +2332,7 @@ public class PostHog private constructor(
getPreferences().clear(except = except.toList())
}
remoteConfig?.clear()
clearFeatureFlagsLoaded()
featureFlagsCalled.clear()
lastScreenName = null
synchronized(cachedPersonPropertiesLock) {
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions posthog/src/main/java/com/posthog/PostHogConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading