Skip to content

chore: LocationTracker coordinator with rate-limited location sends#661

Merged
Shahroz16 merged 5 commits intofeat/real-time-locationfrom
feat/location-event-plugin
Feb 23, 2026
Merged

chore: LocationTracker coordinator with rate-limited location sends#661
Shahroz16 merged 5 commits intofeat/real-time-locationfrom
feat/location-event-plugin

Conversation

@Shahroz16
Copy link
Contributor

@Shahroz16 Shahroz16 commented Feb 19, 2026

Summary

  • Extract location state management from CustomerIO.kt into a dedicated LocationTracker coordinator
  • Rate-limit "Location Update" track events to once per 24 hours — location is always cached for identify enrichment but tracks are only sent when stale or when compensating for an
    identify that lacked location context
  • Remove the internal marker guard on "Location Update" track events (to be revisited with a better approach)

Behavior

Scenario Location cached for identify? "Location Update" track sent?
First-ever location received Yes Yes (no prior timestamp)
Location received within 24h of last track Yes No
Location received after 24h since last track Yes Yes
Identify called with location available Enriched in context
Identify called without location, then location arrives Yes Yes (follow-up for identify)
clearIdentify() after identify-without-location Follow-up debt voided
Cold start, persisted location < 24h old Restored No
Cold start, persisted location >= 24h old Restored Yes (stale re-send)
App killed before location arrives, then cold start Nothing to restore No
## Test plan
  • Verify setLastKnownLocation() sends track on first call, suppresses subsequent calls within 24h
  • Verify identify() without location sets follow-up debt; first location arrival sends track and clears debt
  • Verify clearIdentify() voids follow-up debt
  • Verify cold start restores persisted location for identify enrichment
  • Verify cold start re-sends track when persisted location is >24h stale
  • Verify identify events include context.location_latitude and context.location_longitude when location is available

Note

Medium Risk
Changes event emission/enrichment behavior and introduces persisted (encrypted) storage plus new rate-limiting logic, which could affect analytics payload compatibility and when location tracks are sent.

Overview
Adds a new LocationTracker + LocationPlugin flow that persists last-known location across restarts and enriches Segment identify events with context.location_latitude/context.location_longitude.

Location updates are now rate-limited: incoming TrackLocationEvents always update cached/persisted coordinates, but only emit the "Location Update" track when an identify previously occurred without location context or when the last send is stale (>=24h); on SDK startup, a stale persisted location is re-sent.

Introduces a LocationPreferenceStore (wired via SDKComponentExt) with Android Keystore-backed AES-GCM encryption fallback, and adjusts the location track payload keys from lat/lng to latitude/longitude.

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

Shahroz16 and others added 2 commits February 18, 2026 23:40
- Enrich identify events with last known lat/lng in context so
  Customer.io knows user location at identification time.
- Block consumer-sent "Location Update" track events to prevent
  backend flooding. Only SDK-internal location events pass through
  using an internal marker that is stripped before delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Shahroz16 Shahroz16 requested a review from a team as a code owner February 19, 2026 14:32
@github-actions
Copy link

github-actions bot commented Feb 19, 2026

Sample app builds 📱

Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request.


@codecov
Copy link

codecov bot commented Feb 19, 2026

Codecov Report

❌ Patch coverage is 24.00000% with 95 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (feat/real-time-location@1e96357). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...o/customer/datapipelines/store/PreferenceCrypto.kt 9.30% 38 Missing and 1 partial ⚠️
...customer/datapipelines/location/LocationTracker.kt 19.04% 30 Missing and 4 partials ⚠️
...mer/datapipelines/store/LocationPreferenceStore.kt 43.75% 9 Missing ⚠️
...ines/src/main/kotlin/io/customer/sdk/CustomerIO.kt 46.15% 5 Missing and 2 partials ⚠️
...o/customer/datapipelines/plugins/LocationPlugin.kt 33.33% 5 Missing and 1 partial ⚠️
Additional details and impacted files
@@                    Coverage Diff                     @@
##             feat/real-time-location     #661   +/-   ##
==========================================================
  Coverage                           ?   67.02%           
  Complexity                         ?      773           
==========================================================
  Files                              ?      146           
  Lines                              ?     4443           
  Branches                           ?      598           
==========================================================
  Hits                               ?     2978           
  Misses                             ?     1231           
  Partials                           ?      234           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions
Copy link

  • kotlin_compose: feat/location-event-plugin (1771511598)

"lat" to location.latitude,
"lng" to location.longitude
"latitude" to location.latitude,
"longitude" to location.longitude
Copy link

Choose a reason for hiding this comment

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

Track event property keys renamed, breaking server contract

High Severity

The "Location Update" track event property keys changed from "lat" and "lng" to "latitude" and "longitude". This silently changes the payload schema sent to Customer.io servers. If the backend or any existing campaigns/journeys depend on the original "lat"/"lng" property names, location data processing would break for all customers upgrading to this SDK version. The PR description does not mention this as an intentional change.

Fix in Cursor Fix in Web

@github-actions
Copy link

  • java_layout: feat/location-event-plugin (1771511592)

@github-actions
Copy link

Build available to test
Version: feat-location-event-plugin-SNAPSHOT
Repository: https://central.sonatype.com/repository/maven-snapshots/

@github-actions
Copy link

github-actions bot commented Feb 19, 2026

📏 SDK Binary Size Comparison Report

Module Last Recorded Size Current Size Change in Size
core 30.39 KB 30.39 KB ✅ No Change
datapipelines 39.17 KB 44.93 KB ⬆️ +5.76KB
messagingpush 30.25 KB 30.25 KB ✅ No Change
messaginginapp 106.69 KB 106.69 KB ✅ No Change
tracking-migration 22.89 KB 22.89 KB ✅ No Change

@Shahroz16 Shahroz16 self-assigned this Feb 19, 2026
Copy link
Contributor

@mahmoud-elmorabea mahmoud-elmorabea left a comment

Choose a reason for hiding this comment

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

I would have preferred that that logic lived in the location module, WDYT?

* (e.g. send a "Location Update" track for the newly-identified user).
*/
@Volatile
internal var identifySentWithoutLocation: Boolean = false
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this an in-memory only value? What happens when there an identify in a session then in new launch, the location is acquired

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, the debt flag is now persisted to LocationPreferenceStore via savePendingLocationDebt()/getPendingLocationDebt(). The in-memory field has been removed entirely the store is the single source of truth. The flag is restored on cold start in restorePersistedLocation() and cleared when location arrives or when clearIdentify() is called.

fun restorePersistedLocation() {
val lat = locationPreferenceStore.getLatitude() ?: return
val lng = locationPreferenceStore.getLongitude() ?: return
locationPlugin.lastLocation = Event.LocationData(latitude = lat, longitude = lng)
Copy link
Contributor

Choose a reason for hiding this comment

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

Is locationPlugin.lastLocation access thread safe?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. lastLocation is declared @Volatile. The operations are simple reference reads (lastLocation ?: return payload) and writes (lastLocation = location) both atomic on the JVM. There are no compound read-modify-write operations. @Volatile guarantees cross-thread visibility, which is all that's needed here.

companion object {
private const val KEY_LATITUDE = "latitude"
private const val KEY_LONGITUDE = "longitude"
private const val KEY_LAST_SENT_TIMESTAMP = "last_sent_timestamp"
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we prefix these to avoid any future collisions

Copy link
Contributor Author

Choose a reason for hiding this comment

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

they are under separate directory, io.customer.sdk.location.${context.packageName} so doubt collision, but sure can do add prefix

analytics.add(ApplicationLifecyclePlugin())

// Restore persisted location so identify events have context immediately
locationTracker.restorePersistedLocation()
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this happen on main thread?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes on called thread, might change in next PR where i am updating logic to cater guardrails


if (!locationTracker.hasLocationContext()) {
locationTracker.onIdentifySentWithoutLocation()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should ignore this with an error, there are APIs that can be used to do that

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Could you clarify which APIs you're referring to? Currently the identify proceeds normally and the debt is recorded silently. when location arrives, the SDK sends a compensating Location Update track event. The identify call itself is never blocked or errored, since the location module is optional and apps that don't use location would have every identify() fail.?

@mahmoud-elmorabea mahmoud-elmorabea requested a review from a team February 19, 2026 19:47
@github-actions
Copy link

  • java_layout: feat/location-event-plugin (1771541637)

@github-actions
Copy link

  • kotlin_compose: feat/location-event-plugin (1771541628)

@github-actions
Copy link

  • java_layout: feat/location-event-plugin (1771583421)

@github-actions
Copy link

  • kotlin_compose: feat/location-event-plugin (1771583420)

* (e.g. send a "Location Update" track for the newly-identified user).
*/
@Volatile
internal var identifySentWithoutLocation: Boolean = false
Copy link

Choose a reason for hiding this comment

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

Follow-up debt flag lost on app restart

Medium Severity

identifySentWithoutLocation is an in-memory @Volatile boolean that is never persisted. If identify() is called without location context and the app is killed before any location arrives, this flag is lost on cold start. When the location eventually arrives in the new session, no compensating "Location Update" track is sent for the previous identify, silently dropping the follow-up obligation.

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.

its removed in the other PR

@github-actions
Copy link

  • java_layout: feat/location-event-plugin (1771584877)

@github-actions
Copy link

  • kotlin_compose: feat/location-event-plugin (1771584891)

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 2 potential issues.


if (!locationTracker.hasLocationContext()) {
locationTracker.onIdentifySentWithoutLocation()
}
Copy link

Choose a reason for hiding this comment

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

Race between identify and location event handling

Low Severity

identifyImpl (running inside synchronized(this)) performs a compound check-then-act on hasLocationContext() and identifySentWithoutLocation, while onLocationReceived (running on the event bus coroutine dispatcher, outside any lock) concurrently writes lastLocation and reads the same flag. The @Volatile annotation ensures visibility of individual reads/writes but does not make the compound operations atomic across both methods. This can leave identifySentWithoutLocation set to true even when the identify was enriched with location, causing a spurious compensating track on the next location event.

Additional Locations (2)

Fix in Cursor Fix in Web

}

logger.info("identify profile with identifier $userId and traits $traits")

Copy link

Choose a reason for hiding this comment

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

Location debt not cleared when switching identified profiles

Low Severity

When isChangingIdentifiedProfile is true, identifyImpl does not call locationTracker.onUserReset(). The onUserReset KDoc itself states "the debt belongs to the identified user, and once they're gone the follow-up location track is no longer owed." If user A identified without location (setting the debt flag), and then user B identifies directly (without clearIdentify) with location available, the stale debt from user A persists. The next location event then sends a spurious compensating "Location Update" track attributed to user B's profile.

Additional Locations (1)

Fix in Cursor Fix in Web

Comment on lines +86 to +89
val lat = locationPreferenceStore.getLatitude() ?: return null
val lng = locationPreferenceStore.getLongitude() ?: return null

if (!isStale()) return null
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't isStale be checked before instead?

Suggested change
val lat = locationPreferenceStore.getLatitude() ?: return null
val lng = locationPreferenceStore.getLongitude() ?: return null
if (!isStale()) return null
if (!isStale()) return null
val lat = locationPreferenceStore.getLatitude() ?: return null
val lng = locationPreferenceStore.getLongitude() ?: return null

Copy link
Contributor Author

Choose a reason for hiding this comment

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

removed in #662

@Shahroz16 Shahroz16 merged commit 283ab9f into feat/real-time-location Feb 23, 2026
37 checks passed
@Shahroz16 Shahroz16 deleted the feat/location-event-plugin branch February 23, 2026 12:53
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