Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/android-v4-major.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
- **Breaking:** remove the deprecated `getFeatureFlagPayload()`; use `getFeatureFlagResult(key)?.payload` instead
- **Breaking:** remove the deprecated `PostHogSessionReplayConfig.debouncerDelayMs`; use `throttleDelayMs` instead
- **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, returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()`
2 changes: 1 addition & 1 deletion .changeset/server-keep-on-feature-flags.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
'posthog-server': patch
'posthog-server': minor
---

Move `PostHogOnFeatureFlags` into `posthog-server`; its API is unchanged
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.posthog.BuildConfig
import com.posthog.PostHogBeforeSend
import com.posthog.PostHogEncryption
import com.posthog.PostHogExperimental
import com.posthog.PostHogFeatureFlagsCallback
import com.posthog.PostHogIntegration
import com.posthog.PostHogOnFeatureFlags
import com.posthog.server.internal.PostHogFeatureFlags
Expand Down Expand Up @@ -313,7 +312,6 @@ public open class PostHogConfig constructor(
maxBatchSize = maxBatchSize,
flushIntervalSeconds = flushIntervalSeconds,
encryption = encryption,
onFeatureFlags = onFeatureFlags?.let { callback -> PostHogFeatureFlagsCallback { callback.loaded() } },
proxy = proxy,
remoteConfigProvider = { config, api, _, _, _, _ ->
PostHogFeatureFlags(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.posthog.server

import com.posthog.PostHogFeatureFlagsLoaded
import com.posthog.PostHogOnFeatureFlags
import java.net.Proxy
import kotlin.test.Test
Expand Down Expand Up @@ -149,8 +148,7 @@ internal class PostHogConfigTest {
@Test
fun `asCoreConfig creates core config with all properties`() {
val mockEncryption = createMockEncryption()
var onFeatureFlagsCalls = 0
val mockOnFeatureFlags = PostHogOnFeatureFlags { onFeatureFlagsCalls++ }
val mockOnFeatureFlags = PostHogOnFeatureFlags { }

val config =
PostHogConfig(
Expand Down Expand Up @@ -183,8 +181,7 @@ internal class PostHogConfigTest {
assertEquals(30, coreConfig.maxBatchSize)
assertEquals(45, coreConfig.flushIntervalSeconds)
assertEquals(mockEncryption, coreConfig.encryption)
coreConfig.onFeatureFlags!!.loaded(PostHogFeatureFlagsLoaded(emptyMap(), errorsLoading = false))
assertEquals(1, onFeatureFlagsCalls)
assertNull(coreConfig.onFeatureFlags)
}

@Test
Expand Down
16 changes: 12 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 @@ -357,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 @@ -407,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 @@ -1069,10 +1077,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
122 changes: 108 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,84 @@ 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 remote config thread, 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(): (errorsLoading: Boolean) -> Unit {
val generation = featureFlagsGeneration
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 { deliverFeatureFlags(it, loaded) }
}

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.
if (!listener.active || lastFeatureFlagsLoaded !== 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 @@ -398,13 +465,16 @@ public class PostHog private constructor(
// Bootstrap already seeded the flags in RemoteConfig's init; fire the flags-loaded
// callbacks now so listeners aren't blocked on the first network response.
if (remoteConfig?.hasBootstrapFlags() == true) {
internalOnFeatureFlagsLoaded()
startSessionReplayOnFeatureFlagsLoaded()
// listeners run on the remote config thread, queued ahead of setup's own load
val generation = featureFlagsGeneration
remoteConfigExecutor.executeSafely { notifyFeatureFlagsListeners(errorsLoading = false, generation) }
notifyFeatureFlagsCallback(config.onFeatureFlags, remoteConfig?.getFeatureFlags(), errorsLoading = false)
}
when {
config.remoteConfig ->
loadRemoteConfigRequest(
internalOnFeatureFlagsLoaded,
internalOnFeatureFlagsLoaded(),
config.onFeatureFlags,
)

Expand Down Expand Up @@ -633,6 +703,7 @@ public class PostHog private constructor(
pushSubscriptionManager = null

featureFlagsCalled.clear()
clearFeatureFlagsLoaded()
lastScreenName = null
synchronized(replayDebugPropertiesLock) {
lastReplayDebugPropertiesAt = null
Expand Down Expand Up @@ -1941,13 +2012,32 @@ 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 {
// Read at delivery time so a delivery already queued can't be followed by an older value,
// and skip it if that delivery already passed these values.
val current = lastFeatureFlagsLoaded
if (current != null && current !== listener.lastDelivered) {
deliverFeatureFlags(listener, current)
Comment on lines +2027 to +2029

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Replay flags that have already loaded from persistent cache

With automatic flag loading disabled, getFeatureFlagResult() hydrates persisted flags and marks them loaded, but leaves lastFeatureFlagsLoaded null. A subsequent onFeatureFlags() subscriber receives nothing until an explicit reload, despite current flags being available. Initialize the replay snapshot from already loaded remote-config state.

}
}

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 @@ -1977,7 +2067,7 @@ public class PostHog private constructor(
}

private fun loadRemoteConfigRequest(
internalOnFeatureFlags: (() -> Unit)?,
internalOnFeatureFlags: ((errorsLoading: Boolean) -> Unit)?,
onFeatureFlags: PostHogFeatureFlagsCallback?,
) {
@Suppress("UNCHECKED_CAST")
Expand Down Expand Up @@ -2234,6 +2324,7 @@ public class PostHog private constructor(
getPreferences().clear(except = except.toList())
}
remoteConfig?.clear()
clearFeatureFlagsLoaded()
featureFlagsCalled.clear()
lastScreenName = null
synchronized(cachedPersonPropertiesLock) {
Expand Down Expand Up @@ -2720,6 +2811,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
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ 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
* It's used by `PostHogConfig.onFeatureFlags`, `reloadFeatureFlags` and `onFeatureFlags`. It runs on a background
* thread, or right away on the calling thread when no load is attempted (for example, the SDK is
* disabled). Hop to the main thread before touching UI.
* disabled). With bootstrapped flags, `PostHogConfig.onFeatureFlags` also runs once on the thread
* calling `setup()`, before the first load finishes. Hop to the main thread before touching UI.
*
* Example:
* ```kotlin
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
29 changes: 29 additions & 0 deletions posthog/src/main/java/com/posthog/PostHogInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,35 @@ public interface PostHogInterface : PostHogCoreInterface {
*/
public fun reloadFeatureFlags(onFeatureFlags: PostHogFeatureFlagsCallback? = null)

/**
* Registers a callback that's invoked whenever feature flags are loaded or updated.
*
* The callback runs on the SDK's background remote config thread, never the main 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
Expand Down
Loading
Loading