From 9a47c8ebb1df9bb573a2e66810e6f06039796604 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 14:22:29 +0300 Subject: [PATCH 1/3] fix: identify anonymous user when id already matches persisted distinct id --- .changeset/eager-maps-leave.md | 5 ++ posthog/src/main/java/com/posthog/PostHog.kt | 30 ++++++++++ .../java/com/posthog/PostHogBootstrapTest.kt | 56 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 .changeset/eager-maps-leave.md diff --git a/.changeset/eager-maps-leave.md b/.changeset/eager-maps-leave.md new file mode 100644 index 000000000..e50f64695 --- /dev/null +++ b/.changeset/eager-maps-leave.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Fix `identify()` leaving a user anonymous when the supplied ID already matches the persisted distinct ID (for example after a non-identified bootstrap seeded the same ID). The SDK now marks the user identified and captures a person-processed `$set` event. diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 7b452a567..41ac02f43 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1274,6 +1274,7 @@ public class PostHog private constructor( } val hasDifferentDistinctId = previousDistinctId != distinctId + val shouldTransitionToIdentified = !hasDifferentDistinctId && !isIdentified if (hasDifferentDistinctId && !isIdentified) { // this has to be set before capture since this flag will be read during the event // capture @@ -1308,6 +1309,35 @@ public class PostHog private constructor( } // we need to make sure the user props update is for the same user // otherwise they have to reset and identify again + } else if (shouldTransitionToIdentified) { + // Matching id while still anonymous (e.g. a non-identified bootstrap seeded the same + // id): upgrade to identified and emit one person-processed $set — there is no + // anonymous id to merge, so no $identify (matches posthog-js). + synchronized(identifiedLock) { + isIdentified = true + } + this.distinctId = distinctId + + setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce) + + capture( + PostHogEventName.SET.event, + distinctId = distinctId, + userProperties = userProperties ?: emptyMap(), + userPropertiesSetOnce = userPropertiesSetOnce ?: emptyMap(), + ) + + // The transition event must fire even when an identical property call was cached + // earlier; cache only after capture so deduplication cannot suppress it. + synchronized(cachedPersonPropertiesLock) { + cachedPersonPropertiesHash = getPersonPropertiesHash(distinctId, userProperties, userPropertiesSetOnce) + } + + // The identified state itself is not part of the flags request; reload only when the + // caller supplied properties that can affect flag evaluation. + if ((userProperties?.isNotEmpty() == true || userPropertiesSetOnce?.isNotEmpty() == true) && reloadFeatureFlags) { + reloadFeatureFlags(config?.onFeatureFlags) + } } else if (!hasDifferentDistinctId && (userProperties?.isNotEmpty() == true || userPropertiesSetOnce?.isNotEmpty() == true)) { if (shouldCapturePersonPropertiesEvent( distinctId, diff --git a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt index 60d00b2bf..5a40be979 100644 --- a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt @@ -225,6 +225,62 @@ internal class PostHogBootstrapTest { sut.close() } + @Test + fun `identify upgrades a non-identified bootstrap whose id matches the identify id`() { + val http = mockHttp() + val prefs = PostHogMemoryPreferences() + // Non-identified bootstrap seeds the id as the anonymous id, so distinct id already + // equals the id we later identify with while the user is still anonymous. + val sut = + getSut( + host = http.url("/").toString(), + bootstrap = PostHogBootstrapConfig(distinctId = "user-123"), + cachePreferences = prefs, + reloadFeatureFlags = false, + ) + + sut.identify("user-123") + + queueExecutor.shutdownAndAwaitTermination() + + // No $identify (nothing to merge); a single person-processed $set marks the transition. + val event = firstEvent(http, "\$set") + assertEquals("user-123", event.distinctId) + assertEquals(true, event.properties?.get("\$process_person_profile")) + assertEquals(true, prefs.getValue(IS_IDENTIFIED)) + + sut.close() + http.shutdown() + } + + @Test + fun `a repeated matching-id identify does not emit a second set event`() { + val http = mockHttp() + val prefs = PostHogMemoryPreferences() + val sut = + getSut( + host = http.url("/").toString(), + bootstrap = PostHogBootstrapConfig(distinctId = "user-123"), + cachePreferences = prefs, + reloadFeatureFlags = false, + flushAt = 2, + ) + + sut.identify("user-123") // transition: one $set + sut.identify("user-123") // already identified: no event + sut.capture("event") // flushes the batch (flushAt = 2) + + queueExecutor.shutdownAndAwaitTermination() + + val batch = serializer.deserialize(http.takeRequest().body.unGzip().reader())!! + assertEquals(2, batch.batch.size) + assertEquals("\$set", batch.batch[0].event) + assertEquals("event", batch.batch[1].event) + + sut.close() + http.shutdown() + } + @Test fun `blank bootstrap distinct id is a no-op`() { val sut = getSut(bootstrap = PostHogBootstrapConfig(distinctId = " ")) From aa31ce0eacf0c1ad6ba77f3335cfe36aeb1e038f Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 16:00:20 +0300 Subject: [PATCH 2/3] chore: declare posthog core in changeset --- .changeset/eager-maps-leave.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/eager-maps-leave.md b/.changeset/eager-maps-leave.md index e50f64695..7caa1c3f0 100644 --- a/.changeset/eager-maps-leave.md +++ b/.changeset/eager-maps-leave.md @@ -1,4 +1,5 @@ --- +"posthog": patch "posthog-android": patch --- From 8ab7c28d16674f786a8d6fa1e7739abcff69b909 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 22:01:03 +0300 Subject: [PATCH 3/3] fix: make identify transition atomic to prevent duplicate person events --- posthog/src/main/java/com/posthog/PostHog.kt | 23 +++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 41ac02f43..c976358a4 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1274,14 +1274,23 @@ public class PostHog private constructor( } val hasDifferentDistinctId = previousDistinctId != distinctId - val shouldTransitionToIdentified = !hasDifferentDistinctId && !isIdentified - if (hasDifferentDistinctId && !isIdentified) { - // this has to be set before capture since this flag will be read during the event - // capture - synchronized(identifiedLock) { + + // Read isIdentified, decide the transition, and persist it atomically so two concurrent + // identify() calls on an anonymous user can't both observe isIdentified == false and each + // emit a person-processed event for the same identity transition. isIdentified must also be + // set before capture() below, which reads it during event enrichment. + val shouldIdentify: Boolean + val shouldTransitionToIdentified: Boolean + synchronized(identifiedLock) { + val alreadyIdentified = isIdentified + shouldIdentify = hasDifferentDistinctId && !alreadyIdentified + shouldTransitionToIdentified = !hasDifferentDistinctId && !alreadyIdentified + if (shouldIdentify || shouldTransitionToIdentified) { isIdentified = true } + } + if (shouldIdentify) { capture( PostHogEventName.IDENTIFY.event, distinctId = distinctId, @@ -1313,9 +1322,7 @@ public class PostHog private constructor( // Matching id while still anonymous (e.g. a non-identified bootstrap seeded the same // id): upgrade to identified and emit one person-processed $set — there is no // anonymous id to merge, so no $identify (matches posthog-js). - synchronized(identifiedLock) { - isIdentified = true - } + // isIdentified was already set above under identifiedLock. this.distinctId = distinctId setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce)