Skip to content

feat: add device bucketing support#481

Open
dmarticus wants to merge 4 commits intomainfrom
dmarticus/device-bucketing
Open

feat: add device bucketing support#481
dmarticus wants to merge 4 commits intomainfrom
dmarticus/device-bucketing

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

Summary

  • Adds a persistent $device_id to the Android SDK for stable device-level feature flag bucketing across identity changes
  • The device ID is seeded from the anonymous ID on first init, persists across identify() and reset() calls, and is sent in /flags request payloads
  • Ports the equivalent feature from posthog-js#3340 (React Native SDK) to Android

Problem

Users running experiments targeting anonymous users see variant assignments flip when identify() or reset() changes the distinct_id. The hash changes, causing a different bucket and different variant for the same physical device.

Changes

  • PostHogPreferences: Add DEVICE_ID constant and include in ALL_INTERNAL_KEYS
  • PostHogFlagsRequest: Add optional deviceId parameter, serialized as $device_id
  • PostHogApi.flags(): Thread deviceId parameter to PostHogFlagsRequest
  • PostHogRemoteConfig: Read deviceId from cachePreferences in executeFeatureFlags() and pass to API
  • PostHog.kt:
    • initDeviceId() — seeds device_id from anonymous ID on first setup
    • reset() — preserves DEVICE_ID across resets (alongside VERSION and BUILD)
    • getDeviceId() — public method with lazy-init fallback for SDK upgrades
  • PostHogInterface: Add getDeviceId() to public API surface

Test plan

  • 3 new PostHogFlagsRequestTest tests (includes/excludes device_id)
  • 7 new PostHogTest tests (init, persistence, identify/reset preservation, flags payload, lazy-init upgrades)
  • All existing core tests pass (116 total)
  • All existing server tests pass

Add a persistent $device_id that survives identify() and reset() calls,
enabling stable device-level feature flag bucketing across identity
changes. The device ID is seeded from the anonymous ID on first init
and sent in /flags request payloads.
@dmarticus dmarticus requested a review from a team as a code owner April 8, 2026 18:21
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

posthog-android Compliance Report

Date: 2026-04-09 18:05:12 UTC
Duration: 198ms

✅ All Tests Passed!

0/0 tests passed


getPreferences().setValue(DISTINCT_ID, value)
}

private fun initDeviceId() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

doing the same as getDeviceId
should we just call getDeviceId instead?

Comment on lines +1308 to +1317
val deviceId = getPreferences().getValue(DEVICE_ID) as? String
if (deviceId.isNullOrBlank()) {
// Lazy init for upgrades: existing installs won't have a device_id yet
val anonId = anonymousId
if (anonId.isNotBlank()) {
getPreferences().setValue(DEVICE_ID, anonId)
return anonId
}
return ""
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we should use a lock here similar to anonymousLock

- Remove initDeviceId() and call getDeviceId() during setup instead,
  since they do the same thing
- Add deviceIdLock to synchronize getDeviceId() for thread safety,
  matching the pattern used by anonymousLock
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.

2 participants