diff --git a/.changeset/device-bucketing.md b/.changeset/device-bucketing.md new file mode 100644 index 00000000..e224592b --- /dev/null +++ b/.changeset/device-bucketing.md @@ -0,0 +1,5 @@ +--- +"posthog": minor +--- + +feat: add device bucketing support for stable feature flag assignment across identity changes diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt index ec6bc86b..9eacfd5c 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -167,6 +167,10 @@ public class PostHogFake : PostHogInterface { return "" } + override fun getDeviceId(): String { + return "" + } + override fun debug(enable: Boolean) { } diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt index 0f4f98e1..d35d1e1d 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt @@ -293,7 +293,14 @@ internal class PostHogFeatureFlags( } return try { - val response = api.flags(distinctId, null, groups, personProperties, groupProperties) + val response = + api.flags( + distinctId, + anonymousId = null, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + ) val flags = response?.flags cache.put( cacheKey, diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index de5bd199..a2f1cd3a 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -33,6 +33,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun endSession ()V public fun flush ()V 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; @@ -73,6 +74,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun endSession ()V public fun flush ()V 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; @@ -311,6 +313,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun distinctId ()Ljava/lang/String; public abstract fun endSession ()V public abstract fun getConfig ()Lcom/posthog/PostHogConfig; + public abstract fun getDeviceId ()Ljava/lang/String; public abstract fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;)Ljava/lang/Object; public abstract fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; public abstract fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/Boolean;)Lcom/posthog/FeatureFlagResult; @@ -634,8 +637,8 @@ public final class com/posthog/internal/MultiVariateConfig { public final class com/posthog/internal/PostHogApi { public fun (Lcom/posthog/PostHogConfig;)V public final fun batch (Ljava/util/List;)V - public final fun flags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/PostHogFlagsResponse; - public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; + public final fun flags (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/PostHogFlagsResponse; + public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; public final fun localEvaluation (Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationApiResponse; public static synthetic fun localEvaluation$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/LocalEvaluationApiResponse; public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; @@ -781,6 +784,7 @@ public abstract interface class com/posthog/internal/PostHogPreferences { public static final field ANONYMOUS_ID Ljava/lang/String; public static final field BUILD Ljava/lang/String; public static final field Companion Lcom/posthog/internal/PostHogPreferences$Companion; + public static final field DEVICE_ID Ljava/lang/String; public static final field DISTINCT_ID Ljava/lang/String; public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; @@ -797,6 +801,7 @@ public abstract interface class com/posthog/internal/PostHogPreferences { public final class com/posthog/internal/PostHogPreferences$Companion { public static final field ANONYMOUS_ID Ljava/lang/String; public static final field BUILD Ljava/lang/String; + public static final field DEVICE_ID Ljava/lang/String; public static final field DISTINCT_ID Ljava/lang/String; public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 690e144f..594dcdde 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -10,6 +10,7 @@ import com.posthog.internal.PostHogOnRemoteConfigLoaded import com.posthog.internal.PostHogPreferences.Companion.ALL_INTERNAL_KEYS import com.posthog.internal.PostHogPreferences.Companion.ANONYMOUS_ID import com.posthog.internal.PostHogPreferences.Companion.BUILD +import com.posthog.internal.PostHogPreferences.Companion.DEVICE_ID import com.posthog.internal.PostHogPreferences.Companion.DISTINCT_ID import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED @@ -53,6 +54,7 @@ public class PostHog private constructor( private val reloadFeatureFlags: Boolean = true, ) : PostHogInterface, PostHogStateless() { private val anonymousLock = Any() + private val deviceIdLock = Any() private val identifiedLock = Any() private val groupsLock = Any() private val personProcessingLock: Any = Any() @@ -175,6 +177,11 @@ public class PostHog private constructor( legacyPreferences(config, config.serializer) + // 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(). + getDeviceId() + super.enabled = true queue.start() @@ -1224,9 +1231,10 @@ public class PostHog private constructor( return } - // only remove properties, preserve BUILD and VERSION keys in order to fix over-sending - // of 'Application Installed' events and under-sending of 'Application Updated' events - val except = mutableListOf(VERSION, BUILD) + // Preserve BUILD and VERSION to prevent over-sending "Application Installed" events + // and under-sending "Application Updated" events. Preserve DEVICE_ID to maintain + // stable feature flag bucketing across identity changes. + val except = mutableListOf(VERSION, BUILD, DEVICE_ID) // preserve the ANONYMOUS_ID if reuseAnonymousId is enabled (for preserving a guest user // account on the device) if (config?.reuseAnonymousId == true) { @@ -1283,6 +1291,25 @@ public class PostHog private constructor( return distinctId } + override fun getDeviceId(): String { + if (!isEnabled()) { + return "" + } + synchronized(deviceIdLock) { + val deviceId = getPreferences().getValue(DEVICE_ID) as? String + if (deviceId.isNullOrBlank()) { + // Lazy init for upgrades: existing installs won't have a device_id yet + val anonId = anonymousId + if (anonId.isNotBlank()) { + getPreferences().setValue(DEVICE_ID, anonId) + return anonId + } + return "" + } + return deviceId + } + } + override fun startSession() { if (!isEnabled()) { return @@ -1627,6 +1654,8 @@ public class PostHog private constructor( override fun distinctId(): String = shared.distinctId() + override fun getDeviceId(): String = shared.getDeviceId() + override fun debug(enable: Boolean) { shared.debug(enable) } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 5f3d4522..1f3f77af 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -154,6 +154,15 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun distinctId(): String + /** + * Returns the stable device identifier used for device-level feature flag bucketing. + * This ID persists across [identify] and [reset] calls, only changing on a fresh + * app install, manual cache clearing, or OS-initiated storage cleanup. + * + * @return The device ID, or an empty string if not yet initialized + */ + public fun getDeviceId(): String + /** * Starts a session * The SDK will automatically start a session when you call [setup] diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index c96e8e90..0211a6e6 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -134,6 +134,7 @@ public class PostHogApi( public fun flags( distinctId: String, anonymousId: String? = null, + deviceId: String? = null, groups: Map? = null, personProperties: Map? = null, groupProperties: Map>? = null, @@ -143,6 +144,7 @@ public class PostHogApi( config.apiKey, distinctId, anonymousId = anonymousId, + deviceId = deviceId, groups, personProperties, groupProperties, diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt index 7fae90b0..e38045e0 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt @@ -9,6 +9,7 @@ internal class PostHogFlagsRequest( apiKey: String, distinctId: String, anonymousId: String? = null, + deviceId: String? = null, groups: Map? = null, personProperties: Map? = null, groupProperties: Map>? = null, @@ -21,6 +22,9 @@ internal class PostHogFlagsRequest( if (!anonymousId.isNullOrBlank()) { this["\$anon_distinct_id"] = anonymousId } + if (!deviceId.isNullOrBlank()) { + this["\$device_id"] = deviceId + } if (groups?.isNotEmpty() == true) { this["groups"] = groups } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index d755cee4..af2df67d 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -46,6 +46,7 @@ public interface PostHogPreferences { public const val LAST_SEEN_SURVEY_DATE: String = "lastSeenSurveyDate" public const val VERSION: String = "version" public const val BUILD: String = "build" + public const val DEVICE_ID: String = "deviceId" public const val STRINGIFIED_KEYS: String = "stringifiedKeys" public val ALL_INTERNAL_KEYS: Set = @@ -64,6 +65,7 @@ public interface PostHogPreferences { LAST_SEEN_SURVEY_DATE, VERSION, BUILD, + DEVICE_ID, STRINGIFIED_KEYS, FEATURE_FLAG_REQUEST_ID, FEATURE_FLAG_EVALUATED_AT, diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 497ca144..70582929 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -5,6 +5,7 @@ import com.posthog.PostHogConfig 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 import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAGS import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAGS_PAYLOAD @@ -529,10 +530,13 @@ public class PostHogRemoteConfig( } try { + val deviceId = config.cachePreferences?.getValue(DEVICE_ID) as? String + val response = api.flags( distinctId, anonymousId = anonymousId, + deviceId = deviceId, groups = groups, personProperties = getPersonPropertiesForFlags(), groupProperties = getGroupPropertiesForFlags(), diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index c6d91d5f..c3d39332 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -3016,4 +3016,176 @@ internal class PostHogTest { sut.close() } + + @Test + fun `getDeviceId returns a non-empty value after setup`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + val deviceId = sut.getDeviceId() + assertTrue(deviceId.isNotBlank()) + + sut.close() + } + + @Test + fun `getDeviceId equals anonymousId on first init`() { + val http = mockHttp() + val url = http.url("/") + + val cachePreferences = PostHogMemoryPreferences() + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, cachePreferences = cachePreferences) + + val deviceId = sut.getDeviceId() + val distinctId = sut.distinctId() + + // On first init with no identify, distinctId equals the anonymous ID + assertEquals(distinctId, deviceId) + + sut.close() + } + + @Test + fun `getDeviceId persists across SDK restarts`() { + val http = mockHttp() + val url = http.url("/") + + val cachePreferences = PostHogMemoryPreferences() + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, cachePreferences = cachePreferences) + + val originalDeviceId = sut.getDeviceId() + sut.close() + + // Re-init with same preferences + val sut2 = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, cachePreferences = cachePreferences) + + assertEquals(originalDeviceId, sut2.getDeviceId()) + + sut2.close() + } + + @Test + fun `getDeviceId is preserved across identify`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, personProfiles = PersonProfiles.ALWAYS) + + val originalDeviceId = sut.getDeviceId() + sut.identify("user-123") + + assertEquals(originalDeviceId, sut.getDeviceId()) + assertEquals("user-123", sut.distinctId()) + + sut.close() + } + + @Test + fun `getDeviceId is preserved across reset`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, personProfiles = PersonProfiles.ALWAYS) + + val originalDeviceId = sut.getDeviceId() + sut.identify("user-123") + sut.reset() + + assertEquals(originalDeviceId, sut.getDeviceId()) + // distinct_id should have changed after reset + assertNotEquals("user-123", sut.distinctId()) + + sut.close() + } + + @Test + fun `device_id is sent in flags request`() { + val file = File("src/test/resources/json/flags-v1/basic-flags-no-errors.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + val deviceId = sut.getDeviceId() + assertTrue(deviceId.isNotBlank()) + + sut.reloadFeatureFlags() + remoteConfigExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val body = request.body.unGzip() + val flagsRequest = serializer.deserialize>(body.reader()) + + assertEquals(deviceId, flagsRequest["\$device_id"]) + + sut.close() + } + + @Test + fun `device_id remains the same in flags request after identify`() { + val file = File("src/test/resources/json/flags-v1/basic-flags-no-errors.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + total = 3, + response = + MockResponse() + .setBody(responseFlagsApi), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, personProfiles = PersonProfiles.ALWAYS) + + val deviceId = sut.getDeviceId() + sut.identify("user-123") + + // Drain the $identify batch event that gets flushed automatically + queueExecutor.awaitExecution() + http.takeRequest() + + sut.reloadFeatureFlags() + remoteConfigExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val body = request.body.unGzip() + val flagsRequest = serializer.deserialize>(body.reader()) + + assertEquals(deviceId, flagsRequest["\$device_id"]) + + sut.close() + } + + @Test + fun `getDeviceId lazy-inits for upgrades from older SDK versions`() { + val http = mockHttp() + val url = http.url("/") + + // Simulate an upgrade: preferences have an anonymous ID but no device_id + val cachePreferences = PostHogMemoryPreferences() + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, cachePreferences = cachePreferences) + + // The device_id should have been initialized during setup + val deviceId = sut.getDeviceId() + assertTrue(deviceId.isNotBlank()) + + // Clear the device_id to simulate an upgrade scenario where initDeviceId wasn't called + cachePreferences.remove("deviceId") + + // getDeviceId should lazy-init from the anonymous ID + val lazyDeviceId = sut.getDeviceId() + assertTrue(lazyDeviceId.isNotBlank()) + assertEquals(deviceId, lazyDeviceId) + + sut.close() + } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index 49650f0f..d68c238e 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -101,7 +101,7 @@ internal class PostHogApiTest { val sut = getSut(host = url.toString()) - val response = sut.flags("distinctId", anonymousId = "anonId", emptyMap()) + val response = sut.flags("distinctId", anonymousId = "anonId", groups = emptyMap()) val request = http.takeRequest() @@ -123,7 +123,7 @@ internal class PostHogApiTest { val exc = assertThrows(PostHogApiError::class.java) { - sut.flags("distinctId", anonymousId = "anonId", emptyMap()) + sut.flags("distinctId", anonymousId = "anonId", groups = emptyMap()) } assertEquals(400, exc.statusCode) assertEquals("Client Error", exc.message) @@ -383,7 +383,7 @@ internal class PostHogApiTest { val sut = getSut(host = url.toString(), debug = true, logger = logger) - sut.flags("distinctId", anonymousId = "anonId", emptyMap()) + sut.flags("distinctId", anonymousId = "anonId", groups = emptyMap()) assertTrue( logger.messages.any { it.contains("Request headers for") && it.contains("/flags") }, diff --git a/posthog/src/test/java/com/posthog/internal/PostHogFlagsRequestTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogFlagsRequestTest.kt index aef1c0a8..36a7ce94 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogFlagsRequestTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogFlagsRequestTest.kt @@ -17,7 +17,7 @@ internal class PostHogFlagsRequestTest { API_KEY, DISTINCT_ID, anonymousId = ANON_ID, - groups, + groups = groups, personProperties = personProperties, groupProperties = groupProperties, ) @@ -67,4 +67,35 @@ internal class PostHogFlagsRequestTest { assertEquals(DISTINCT_ID, request["distinct_id"]) assertEquals(null, request["evaluation_contexts"]) } + + @Test + fun `includes device_id when provided`() { + val request = + PostHogFlagsRequest( + API_KEY, + DISTINCT_ID, + deviceId = "device-123", + ) + + assertEquals("device-123", request["\$device_id"]) + } + + @Test + fun `excludes device_id when null`() { + val request = PostHogFlagsRequest(API_KEY, DISTINCT_ID) + + assertEquals(null, request["\$device_id"]) + } + + @Test + fun `excludes device_id when blank`() { + val request = + PostHogFlagsRequest( + API_KEY, + DISTINCT_ID, + deviceId = "", + ) + + assertEquals(null, request["\$device_id"]) + } }