Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/eager-maps-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog": patch
"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.
45 changes: 41 additions & 4 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1274,13 +1274,23 @@ public class PostHog private constructor(
}

val hasDifferentDistinctId = previousDistinctId != distinctId
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,
Expand Down Expand Up @@ -1308,6 +1318,33 @@ 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).
// isIdentified was already set above under identifiedLock.
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,
Expand Down
56 changes: 56 additions & 0 deletions posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostHogBatchEvent>(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 = " "))
Expand Down
Loading