Skip to content

fix: queue user and device ID changes for proper event ordering#349

Closed
polbins wants to merge 14 commits intomainfrom
pintal/AMP-AMP-133018/user-id-change-ordering
Closed

fix: queue user and device ID changes for proper event ordering#349
polbins wants to merge 14 commits intomainfrom
pintal/AMP-AMP-133018/user-id-change-ordering

Conversation

@polbins
Copy link
Contributor

@polbins polbins commented Feb 2, 2026

Describe what this PR is addressing

Identity changes setUserId() / setDeviceId() / reset() followed by track() could send events with stale identity because identity changes and events were processed on different dispatchers with no ordering guarantee.

  • Identity changes ran on amplitudeDispatcher
  • Events processed on storageIODispatcher

No ordering guarantee between the two.

Describe the solution

Dual-path architecture:

  1. Immediate - store.userId = value updates in-memory state synchronously for event enrichment and plugin notifications
  2. Background - super.setUserId() dispatches idContainer.commit() via coroutine for disk persistence

Key changes:

  • Amplitude.kt: Android overrides update store immediately then delegate to core for persistence
  • State.kt: Unified Identity object with synchronized access and idempotent observer notifications
  • AndroidContextPlugin.kt: Refactored to pure createDeviceId() function

Steps to verify the change

Manual verification:

  • Call setUserId("user1") then immediately track("event1")
    • Verify event1 has userId "user1"
  • Call reset() then immediately track("event2")
    • Verify event2 has a new deviceId (not the previous one)

Checklist

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change? No

Note

Medium Risk
Touches core identity/state synchronization and device ID initialization paths, which can affect event attribution and persistence across platforms despite added test coverage.

Overview
Fixes cases where setUserId()/setDeviceId()/reset() followed by track() could emit events with stale identity by updating in-memory identity (State) synchronously and only committing to the identity manager asynchronously.

Android initialization now computes and persists deviceId during buildInternal (preferring configured ID, then stored non-AppSet IDs, then generated), while AndroidContextPlugin is refactored to a pure createDeviceId() helper and deprecates plugin-driven identity setting. Core State is reworked to hold a synchronized Identity object with idempotent observer notifications, ContextPlugin no longer performs deviceId initialization, and new tests assert ordering for setUserId, setDeviceId, identify options, and reset().

Written by Cursor Bugbot for commit 4203557. This will update automatically on new commits. Configure here.

@macroscopeapp
Copy link

macroscopeapp bot commented Feb 2, 2026

Queue identity updates and commit them in android.Timeline to ensure setUserId, setDeviceId, and reset apply before subsequent events on Android

Route setUserId, setDeviceId, and reset through a new android.Timeline.queueSetIdentity message that commits identity changes in FIFO order; initialize deviceId deterministically in android.Amplitude using AndroidContextPlugin.createDeviceId; remove pre-build deviceId setting from plugins and core; add tests for ordering.

🖇️ Linked Issues

Addresses ordering concerns related to Start Session enrichment noted in AMP-133018 by ensuring identity and properties updates precede subsequent events.

📍Where to Start

Start with processEventMessage and queueSetIdentity in android/src/main/java/com/amplitude/android/Timeline.kt, then review identity initialization in android/src/main/java/com/amplitude/android/Amplitude.kt.


Macroscope summarized 06a697d.

@polbins polbins force-pushed the pintal/AMP-AMP-133018/user-id-change-ordering branch from ca80fc8 to 8b5f5ec Compare February 3, 2026 00:31
@polbins polbins changed the title fix: queue user and device ID changes for proper event ordering fix(android): queue user and device ID changes for proper event ordering Feb 3, 2026
@polbins polbins force-pushed the pintal/AMP-AMP-133018/user-id-change-ordering branch from 2ff8cdd to c57c618 Compare February 3, 2026 18:15
@polbins polbins marked this pull request as ready for review February 3, 2026 18:17
@polbins polbins changed the title fix(android): queue user and device ID changes for proper event ordering fix: queue user and device ID changes for proper event ordering Feb 3, 2026
@polbins polbins requested review from crleona and sojingle February 3, 2026 18:27
@polbins polbins marked this pull request as draft February 3, 2026 18:50
@polbins
Copy link
Contributor Author

polbins commented Feb 3, 2026

Converting to draft again, there is still a case that needs to be handled:
#349 (comment)

@polbins polbins marked this pull request as ready for review February 3, 2026 19:52
Copy link
Contributor

@sojingle sojingle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@polbins polbins force-pushed the pintal/AMP-AMP-133018/user-id-change-ordering branch from 5aab61a to 0139681 Compare February 5, 2026 18:27
@polbins polbins force-pushed the pintal/AMP-AMP-133018/user-id-change-ordering branch from ab4feba to 1a91523 Compare February 5, 2026 20:13
@polbins polbins force-pushed the pintal/AMP-AMP-133018/user-id-change-ordering branch from 1a91523 to 06a697d Compare February 5, 2026 21:38
@polbins polbins force-pushed the pintal/AMP-AMP-133018/user-id-change-ordering branch from 06a697d to d18d353 Compare February 11, 2026 18:58
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.


override fun setDeviceId(deviceId: String): Amplitude {
store.deviceId = deviceId
super.setDeviceId(deviceId)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred identity callbacks can overwrite newer immediate state

Medium Severity

The dual-write architecture introduces a race: the Android setDeviceId/setUserId overrides write to store immediately, then super launches a deferred coroutine that commits to the identity manager, whose AnalyticsIdentityListener callback writes to store again. When two calls happen in succession with different values, the deferred callback from the first call can temporarily revert the store to the stale value, causing events enriched during that window (on storageIODispatcher) to pick up the wrong identity — the exact problem this PR aims to fix.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a pre-existing race in core's coroutine dispatch + listener pattern, not introduced by this PR. Noted, in case it becomes an issue later.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is valid - state would be temporarily overwritten by AnalyticsIdentityListener, leading to the same mismatch between what was set and what we add to events. Does AnalyticsIdentityListener still have to update the state on initialization if we're already updating state from the user/device id setters?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks to me that the onUserIdChange/onDeviceIdChange is the one flagged here.
So I made those noop, since we're updating state ourselves anyway.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can experiment edit the identity too? The callbacks on AnalyticsIdentityListener still may be used even if they aren't used here.

I do see a race condition here specifically in the IdentityUpdateType.Initialized path in AnalyticsIdentityListener -

init(deviceId: A) // sets state deviceId: A, async kicks off AnalyticsIdentityListener.init
setDeviceId: B // sets state deviceId: B
init async callback // sets state deviceId: A again


override fun setDeviceId(deviceId: String): Amplitude {
store.deviceId = deviceId
super.setDeviceId(deviceId)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is valid - state would be temporarily overwritten by AnalyticsIdentityListener, leading to the same mismatch between what was set and what we add to events. Does AnalyticsIdentityListener still have to update the state on initialization if we're already updating state from the user/device id setters?

configuration.deviceId
?: amplitude.store.deviceId?.takeIf { validDeviceId(it, allowAppSetId = false) }
?: createDeviceId()
amplitude.setDeviceId(deviceId)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this saved to storage synchronously in the previous version - could you please maintain that behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initializeDeviceId is no longer called internally, deprecated it just in case some external usage exists for some reason.

Now the actual init path is in buildInternal() which now persists directly

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously all the calls here went from setDeviceId -> amplitude.setDeviceIdInternal (via the AndroidContextPlugin override) -> idContainer.identityManager.editIdentity().setDeviceId(deviceId).commit(). It seems like this now waits for isBuilt in the core amplitude instance.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

if (updateType == IdentityUpdateType.Initialized) {
state.userId = identity.userId
state.deviceId = identity.deviceId
// TODO("update device id based on configuration")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No-op identity callbacks lose state after initialization overwrites

Medium Severity

Making onUserIdChange and onDeviceIdChange no-ops removes the feedback loop that corrected state after the Initialized callback. If setUserId("x") is called before build completes, store.userId is set to "x" immediately. Then createIdentityContainer fires onIdentityChanged(persisted, Initialized), overwriting the store with persisted values (e.g., null). After isBuilt completes, the deferred commit() fires onUserIdChange("x") — which is now a no-op — so the store permanently loses "x". The old code restored state correctly through this callback.

Fix in Cursor Fix in Web

@polbins polbins force-pushed the pintal/AMP-AMP-133018/user-id-change-ordering branch from f365fb3 to 4203557 Compare February 13, 2026 22:22
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

},
)
configuration.deviceId?.let { setDeviceId(it) }
add(ContextPlugin())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core build no longer initializes device ID

High Severity

buildInternal() now sets deviceId only when configuration.deviceId is provided. Since ContextPlugin no longer initializes identity in setup(), fresh core instances can start with store.deviceId == null, causing events enriched by ContextPlugin to carry a missing deviceId.

Additional Locations (1)

Fix in Cursor Fix in Web

@polbins polbins closed this Feb 13, 2026
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