Repository navigation
fix(alias): include distinct_id in $create_alias event properties - #819
posthog[bot] wants to merge 3 commits into
Conversation
The client `alias(alias)` path built `$create_alias` properties containing only `alias`. The cross-SDK alias contract's client acceptance scenario requires the enqueued event's properties to include both `alias` and `distinct_id`. Purely additive: an extra property on one event type, no change to the top-level distinct id, ambient identity, or any existing property. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Generated-By: PostHog Desktop Task-Id: f85eba86-15aa-428c-b47e-b7c63215bf73
posthog-android Compliance ReportDate: 2026-10-12 18:13:05 UTC ✅ All Tests Passed!46/46 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
|
#819 already matches what I had in the closed duplicate PR (#837) for One gap for parity with the stateless/JVM path:
props["distinct_id"] = distinctId
assertEquals("user123", event.properties!!["distinct_id"])Happy to open a tiny follow-up PR once this lands if that is easier than expanding the bot PR. |
Fixed:
Also looked at Server |
dustinbyrne
left a comment
There was a problem hiding this comment.
The nominal payload change matches the canonical client acceptance scenario, and the core/Android changeset scope looks correct. Consider keeping the new identity resolution inside a failure guard and using one source-ID snapshot for both alias fields. Details and unexecuted regression sketches are inline.
AI-assisted review.
| props["alias"] = alias | ||
| // The source identity is also duplicated into the properties so consumers that only | ||
| // read the event properties can resolve both sides of the alias linkage. | ||
| props["distinct_id"] = distinctId |
There was a problem hiding this comment.
[P1] Contain failures from the new identity read
The new distinctId read occurs before capture()’s exception handler. Its getter evaluates anonymousId, which can invoke the supported PostHogConfig.getAnonymousId callback (PostHog.kt:592–629; PostHogConfig.kt:173–177).
A reachable public-entry case is a provider failing during initialization with empty storage:
val config = PostHogConfig("test-token").apply {
getAnonymousId = {
throw IllegalStateException("id provider unavailable")
}
}
val client = PostHog.with(config)
client.alias("user-123")Setup marks the instance enabled before initializing identity (PostHog.kt:326–331). It catches the provider failure (:406–408), and with() returns the still-enabled instance with no anonymous ID. Alias then retries the provider at the new line and lets its exception escape.
Base-to-head causality: the partial initialization is pre-existing. At the merge base, alias resolved identity inside capture(), whose catch logged/dropped the failure (base PostHog.kt:850–860, 959–961). This PR moves that resolution outside the failure boundary.
This violates the established fire-and-forget failure behavior and the pinned capture error contract. It affects core JVM and Android client alias calls, not server aliasStateless. It requires a failing custom provider; successful default identity generation is unaffected. This is source-supported, not an observed execution result.
Consider guarding identity resolution, logging/dropping on failure, and retaining the existing capture pipeline.
Test sketch — not executed:
- Use fresh MockWebServer-backed configuration, empty memory storage, no bootstrap, a recording nonthrowing logger, and an alias before-send counter.
- Configure
getAnonymousIdto count invocations and throw as above. - Initialize through public
PostHog.with(config); confirm setup returned and logged its failure. - Assert:
assertNull(runCatching { client.alias("user-123") }.exceptionOrNull()) - Assert the provider was retried, no alias reached before-send, no request was sent, and the failure was logged. Close SDK/HTTP in
finally.
Expected: no caller exception and no event.
Head-implied result: the exception escapes at line 1324.
Base-implied result: capture catches it.
Not executed during this static review; the proposed focused command is:
./gradlew :posthog:test --tests "com.posthog.PostHogTest"
There was a problem hiding this comment.
🤖 An AI agent wrote this on behalf of @ioannisj.
Fixed in 7679a90. alias() now reads the source id inside a try/catch and logs and drops on failure, so a throwing getAnonymousId doesn't reach the caller anymore. The new alias does not throw when getAnonymousId throws test fails on the previous head with the IllegalStateException.
identify(), group() and distinctId() have the same gap, they've read distinctId unguarded since before this PR. I'd rather do those in a follow-up than widen this one, ok with you?
| // read the event properties can resolve both sides of the alias linkage. | ||
| props["distinct_id"] = distinctId | ||
|
|
||
| capture(PostHogEventName.CREATE_ALIAS.event, properties = props) |
There was a problem hiding this comment.
[P2] Use one source-ID snapshot for both alias fields
Alias reads ambient identity for the new property, then capture() reads it again for the envelope (PostHog.kt:860). Supported concurrent calls can interleave:
// Thread A
client.alias("user-target")
// Thread B
client.identify("known-B")A possible timeline is:
- Alias reads
"anon-A"forproperties.distinct_id. - Another thread completes
identify("known-B"). - Capture reads
"known-B"for the envelope.
The resulting alias event has two different source identities. Individual preference locks do not span both reads, and queueing happens afterward. The canonical client scenario requires both fields to identify the same source.
Base-to-head causality: identity changes before capture chose its envelope were already possible. This PR introduces the contradictory duplicate—not the existing cross-call ordering behavior.
Properties-only and envelope-reading consumers can interpret different linkages. The inspected backend uses the envelope, so this is not evidence of new backend merge corruption (person-merge-service.ts:113–123). Impact requires concurrent identity changes and a consumer reading the duplicate. Browser’s similar code does not establish JVM thread safety.
Consider resolving one local source ID and passing it both into properties and capture’s existing distinctId override, within the failure guard described in the other comment. No global lifecycle lock is needed.
Test sketch — not executed:
- Seed
"anon-A"into memory preferences. Initialize the existing fixture withflushAt = 20, feature preloading disabled, and its internal reload switch disabled. - Wrap preferences only as a timing instrument. On the alias thread’s first
DISTINCT_IDread, save the returned value, signal a latch, wait without holding the delegate lock, then return that saved value. - Start
sut.alias("user-target")on that thread. - While its first read is suspended, complete
sut.identify("known-B"). - Release/join with bounded waits, flush, and drain queue work.
- Select the serialized
$create_aliasevent and assert:- alias target is
"user-target"; - envelope identity equals
properties.distinct_id; - after the snapshot fix, both are
"anon-A".
- alias target is
- Release latches and close resources in cleanup even on failure.
Expected: one internally consistent source snapshot.
Head-implied result: envelope "known-B", property "anon-A".
Base comparison: no alias-specific duplicate existed to contradict the envelope.
Not executed during this static review; the proposed focused command is:
./gradlew :posthog:test --tests "com.posthog.PostHogTest"
There was a problem hiding this comment.
🤖 An AI agent wrote this on behalf of @ioannisj.
Fixed in 7679a90. The id is read once and goes into both properties.distinct_id and capture's distinctId override, so the two can't diverge under a concurrent identify().
My earlier posthog-js comparison was wrong, js does read twice but it's single-threaded, so the reads can't interleave.
I skipped the latch test since with a single read there's no window left for it to hit, and captures an alias event already asserts the envelope matches the property.
💡 Motivation and Context
The client-side
alias(alias)path builds the$create_aliasevent with only analiasproperty. The cross-SDK alias contract's client acceptance scenario (acceptance/public/alias.feature, the@clientscenario) requires the enqueued event's properties to include bothaliasanddistinct_id, so a consumer reading only the event properties can resolve both sides of the linkage. See the Alias spec.Compliance matrix note (
compliance/posthog-android.md→ n1, scored ❌ Fail):The change
PostHog.alias(...)now setsprops["distinct_id"]to the current ambient distinct id alongsidealias. Nothing else moves: the top-leveldistinct_id, the ambient identity, the person-processing guard, and every other property are untouched.buildProperties()has an unrelated session-replay branch that fills in a blankdistinct_idproperty; it only writes when the value is null/blank, so it does not interact with this change.Why it's backwards-compatible: it adds one property to one event type. No existing property changes value, no API signature changes, no behavior is removed. Existing callers and downstream consumers see a strict superset of what they saw before.
Server-side
aliasStateless(distinctId, alias)is left alone — the server acceptance scenario only requires the top-leveldistinct_id, whichcaptureStatelessalready sets correctly.💚 How did you test it?
captures an alias eventunit test to assertproperties["distinct_id"]equals the event's top-level distinct id../gradlew :posthog:test --tests "com.posthog.PostHogTest"— passes../gradlew spotlessCheck— clean.No manual/device testing was performed.
📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileFollow-up work
Other open
posthog-androidgaps in the same matrix that are out of scope here (one contract per PR): Capture Exception (missing top-level$exception_type/$exception_message), Shutdown (close()never flushes), Screen ($screen_nameprecedence), and the Feature Flag Called Tracker allowlist gap shared across 8 SDKs.🤖 Agent context
Autonomy: Fully autonomous
Produced by a scheduled SDK-compliance agent (Claude Opus 5 via PostHog Desktop) that reads the compliance matrices in
PostHog/sdk-specsand implements one backwards-compatible gap per run. This cell was picked over the other openposthog-androidgaps because it is a core identity contract, the remediation is a single additive line with an unambiguous acceptance scenario behind it, and no open PR or branch was already addressing it.One judgement call worth flagging for the reviewer: the Alias spec's prose says "Some stateless/server-oriented implementations also duplicate the source id into
properties.distinct_id; audited mobile client helpers do not require that duplication," which sits in tension with the@clientacceptance scenario that explicitly asserts the property. This PR follows the acceptance scenario (and the matrix verdict). If the intent is really that mobile clients should not duplicate it, the fix belongs insdk-specsinstead and this PR should be closed.Agent-authored — requires human review; not self-merged.
Created with PostHog Desktop
🤖 Generated with Claude Code