Skip to content

fix(alias): include distinct_id in $create_alias event properties - #819

Open
posthog[bot] wants to merge 3 commits into
mainfrom
posthog/android-alias-distinct-id-property
Open

posthog[bot] wants to merge 3 commits into
mainfrom
posthog/android-alias-distinct-id-property

Conversation

@posthog

@posthog posthog Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

The client-side alias(alias) path builds the $create_alias event with only an alias property. The cross-SDK alias contract's client acceptance scenario (acceptance/public/alias.feature, the @client scenario) requires the enqueued event's properties to include both alias and distinct_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):

SDK currently: alias(alias: String) builds props["alias"] = alias and calls capture($create_alias, properties = props) but never sets props["distinct_id"]. buildProperties() also does not inject distinct_id for $create_alias events.

Backwards compatibility: Backward-compatible — adding props["distinct_id"] = distinctId is purely additive to the event payload.

Remediation: Set distinct_id alongside alias in the $create_alias properties map.

The change

PostHog.alias(...) now sets props["distinct_id"] to the current ambient distinct id alongside alias. Nothing else moves: the top-level distinct_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 blank distinct_id property; 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-level distinct_id, which captureStateless already sets correctly.

💚 How did you test it?

  • Extended the existing captures an alias event unit test to assert properties["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

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

Follow-up work

Other open posthog-android gaps 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_name precedence), 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-specs and implements one backwards-compatible gap per run. This cell was picked over the other open posthog-android gaps 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 @client acceptance 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 in sdk-specs instead and this PR should be closed.

Agent-authored — requires human review; not self-merged.


Created with PostHog Desktop

🤖 Generated with Claude Code

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
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-10-12 18:13:05 UTC
Duration: 118346ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 362ms
Format Validation.Event Has Uuid ✅ 34ms
Format Validation.Event Has Lib Properties ✅ 29ms
Format Validation.Distinct Id Is String ✅ 26ms
Format Validation.Token Is Present ✅ 25ms
Format Validation.Custom Properties Preserved ✅ 24ms
Format Validation.Event Has Timestamp ✅ 23ms
Retry Behavior.Retries On 503 ✅ 7026ms
Retry Behavior.Does Not Retry On 400 ✅ 4027ms
Retry Behavior.Does Not Retry On 401 ✅ 4027ms
Retry Behavior.Respects Retry After Header ✅ 7029ms
Retry Behavior.Implements Backoff ✅ 17038ms
Retry Behavior.Retries On 500 ✅ 7019ms
Retry Behavior.Retries On 502 ✅ 7018ms
Retry Behavior.Retries On 504 ✅ 7020ms
Retry Behavior.Max Retries Respected ✅ 17038ms
Deduplication.Generates Unique Uuids ✅ 41ms
Deduplication.Preserves Uuid On Retry ✅ 7016ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 12030ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7019ms
Deduplication.No Duplicate Events In Batch ✅ 43ms
Deduplication.Different Events Have Different Uuids ✅ 27ms
Compression.Sends Gzip When Enabled ✅ 19ms
Batch Format.Uses Proper Batch Structure ✅ 18ms
Batch Format.Flush With No Events Sends Nothing ✅ 12ms
Batch Format.Multiple Events Batched Together ✅ 32ms
Error Handling.Does Not Retry On 403 ✅ 4019ms
Error Handling.Does Not Retry On 413 ✅ 4024ms
Error Handling.Retries On 408 ✅ 5031ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 36ms
Request Payload.Flags Request Uses V2 Query Param ✅ 22ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 24ms
Request Payload.Flags Request Omits Authorization Header ✅ 40ms
Request Payload.Token In Flags Body Matches Init ✅ 24ms
Request Payload.Groups Round Trip ✅ 22ms
Request Payload.Groups Default To Empty Object ✅ 19ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 19ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 21ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 21ms
Request Lifecycle.No Flags Request On Init Alone ✅ 9ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 20ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 34ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 20ms
Retry Behavior.Retries Flags On 502 ✅ 325ms
Retry Behavior.Retries Flags On 504 ✅ 329ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 25ms

@o-mid

o-mid commented Oct 4, 2026

Copy link
Copy Markdown

#819 already matches what I had in the closed duplicate PR (#837) for PostHog.alias() and PostHogTest.

One gap for parity with the stateless/JVM path: aliasStateless still only sets alias in properties. Suggested follow-up (same one-liner + test):

PostHogStateless.kt — in aliasStateless, after props["alias"] = alias:

props["distinct_id"] = distinctId

PostHogStatelessTest.kt — in aliasStateless creates alias event:

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.

@ioannisj ioannisj self-assigned this Oct 7, 2026
@ioannisj

ioannisj commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🤖 An AI agent wrote this on behalf of @ioannisj.

Fixed:

Also looked at alias() and capture() reading distinctId separately. posthog-js reads it twice the same way and ingestion only uses the top-level id, so I left it.

Server aliasStateless stays as is on purpose, the server acceptance scenario only checks the top-level distinct_id.

@ioannisj
ioannisj marked this pull request as ready for review October 7, 2026 14:27
@ioannisj
ioannisj requested a review from a team as a code owner October 7, 2026 14:27

@dustinbyrne dustinbyrne left a comment

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.

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

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.

[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:

  1. Use fresh MockWebServer-backed configuration, empty memory storage, no bootstrap, a recording nonthrowing logger, and an alias before-send counter.
  2. Configure getAnonymousId to count invocations and throw as above.
  3. Initialize through public PostHog.with(config); confirm setup returned and logged its failure.
  4. Assert:
    assertNull(runCatching { client.alias("user-123") }.exceptionOrNull())
  5. 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"

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.

🤖 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)

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] 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:

  1. Alias reads "anon-A" for properties.distinct_id.
  2. Another thread completes identify("known-B").
  3. 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:

  1. Seed "anon-A" into memory preferences. Initialize the existing fixture with flushAt = 20, feature preloading disabled, and its internal reload switch disabled.
  2. Wrap preferences only as a timing instrument. On the alias thread’s first DISTINCT_ID read, save the returned value, signal a latch, wait without holding the delegate lock, then return that saved value.
  3. Start sut.alias("user-target") on that thread.
  4. While its first read is suspended, complete sut.identify("known-B").
  5. Release/join with bounded waits, flush, and drain queue work.
  6. Select the serialized $create_alias event and assert:
    • alias target is "user-target";
    • envelope identity equals properties.distinct_id;
    • after the snapshot fix, both are "anon-A".
  7. 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"

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.

🤖 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.

@dustinbyrne
dustinbyrne requested a review from a team October 7, 2026 15:27

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants