Skip to content

chore: make location module self-contained with generic core interfaces#664

Merged
Shahroz16 merged 5 commits intofeat/real-time-locationfrom
chore-arch-revamp
Feb 27, 2026
Merged

chore: make location module self-contained with generic core interfaces#664
Shahroz16 merged 5 commits intofeat/real-time-locationfrom
chore-arch-revamp

Conversation

@Shahroz16
Copy link
Contributor

@Shahroz16 Shahroz16 commented Feb 25, 2026

Summary

  • Replace location-specific code in datapipelines (LocationPlugin, LocationSyncFilter, LocationSyncStore, TrackLocationEvent subscriber) with two generic core
    interfaces (DataPipeline, ProfileEnrichmentProvider) that any module can use
  • Move all location business logic (sync filter, userId gate, profile switch handling, track event sending) into the location module's LocationTracker, making it fully self-contained
  • Eliminate cross-module EventBus round-trips for location track events — LocationTracker now calls DataPipeline.track() directly

What changed

Core module — new generic interfaces:

  • DataPipeline interface (track + userId) — modules retrieve via SDKComponent.getOrNull()
  • ProfileEnrichmentProvider / ProfileEnrichmentRegistry — modules register to enrich identify events
  • Removed LocationCache, Event.TrackLocationEvent, Event.LocationData (location-specific contracts)

Datapipelines module — zero location awareness:

  • CustomerIO implements DataPipeline, registers itself for module access
  • ProfileEnrichmentPlugin replaces LocationPlugin — generic enrichment that queries all registered providers
  • Removed LocationSyncFilter, LocationSyncStore, TrackLocationEvent subscriber, sendLocationTrack(), and clearSyncedData() calls from identifyImpl()/clearIdentifyImpl()

Location module — fully self-contained:

  • LocationTracker implements ProfileEnrichmentProvider for identify enrichment
  • LocationTracker applies userId gate + sync filter + DataPipeline.track() directly (no EventBus)
  • LocationSyncFilter and LocationSyncStore moved from datapipelines (package change only, logic identical)
  • Profile switch detection via lastKnownUserId with sync filter reset
  • onReset() clears all state (in-memory cache, persisted location, sync filter)

Note

Medium Risk
Changes the location-identify/track flow and moves filtering/state-reset responsibilities across modules; regressions could drop or duplicate location enrichment/track events despite being covered by updated unit tests.

Overview
Decouples location from datapipelines by removing location-specific Event types (Event.LocationData, Event.TrackLocationEvent) and the LocationCache contract, and introducing core internal interfaces for cross-module interaction: DataPipeline (direct track + isUserIdentified) and IdentifyHook/IdentifyHookRegistry (identify context enrichment + synchronous reset hook).

CustomerIO now implements and registers DataPipeline, replaces LocationPlugin with a generic IdentifyContextPlugin that enriches identify events from all registered hooks, and removes the EventBus-driven location track subscriber and sync-filter reset calls. The location module takes ownership of location business logic: LocationTracker implements IdentifyHook, persists/caches coordinates, applies the 24h/1km LocationSyncFilter (moved into the location module), and sends LOCATION_UPDATE via DataPipeline.track instead of publishing through EventBus; tests are updated accordingly and location/datapipelines builds opt into @InternalCustomerIOApi.

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

@Shahroz16 Shahroz16 requested a review from a team as a code owner February 25, 2026 00:35
@Shahroz16 Shahroz16 self-assigned this Feb 25, 2026
@github-actions
Copy link

github-actions bot commented Feb 25, 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.


@github-actions
Copy link

  • java_layout: chore-arch-revamp (1771979798)

@github-actions
Copy link

  • kotlin_compose: chore-arch-revamp (1771979798)

@github-actions
Copy link

Build available to test
Version: chore-arch-revamp-SNAPSHOT
Repository: https://central.sonatype.com/repository/maven-snapshots/

@github-actions
Copy link

github-actions bot commented Feb 25, 2026

📏 SDK Binary Size Comparison Report

Module Last Recorded Size Current Size Change in Size
core 30.97 KB 30.89 KB ⬇️ -0.08KB
datapipelines 42.71 KB 40.56 KB ⬇️ -2.15KB
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

*/
@InternalCustomerIOApi
interface DataPipeline {
val userId: String?
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we exposing userId to check if the user is identified? Should we expose isIdentified instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

userId is exposed if we wanted to also track user being changed, but i can remove it right now and rely on UserChangedEvent

Copy link
Contributor

Choose a reason for hiding this comment

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

Good point, that covers two identify call different ids with no clearIdentify, I will port this to iOS

*/
@InternalCustomerIOApi
interface ProfileEnrichmentProvider {
fun getProfileEnrichmentAttributes(): Map<String, Any>?
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's make this non-null. If there is nothing to enrich, we can return empty map.

class ProfileEnrichmentRegistry {
private val providers = mutableListOf<ProfileEnrichmentProvider>()

@Synchronized
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we sure that @Synchronized is the right tool here? Do plugins use threads or coroutines?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Plugins run synchronously on the caller's thread, EventPlugin.identify() is blocking and executes on whatever thread called analytics.identify(). No coroutines involved at the plugin level.

@Synchronized is the right tool here because register(), getAll(), and clear() are fast, non-suspending operations (list add/copy/clear). A coroutine Mutex would require these to be suspend functions, which doesn't fit the EventPlugin interface.

Copy link
Contributor

Choose a reason for hiding this comment

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

The main idea is that the registry is called from other modules to register themselves and this could expand to more modules in the future. So we can't be sure that this invariant remains true

// Register LocationPlugin as LocationCache so the location module can update it
SDKComponent.registerDependency<LocationCache> { locationPlugin }
// Register this instance as DataPipeline so modules can send track events directly
SDKComponent.registerDependency<DataPipeline> { this }
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 the best way to make this registration? This has to be done before the location module is inialized, correct?

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, it must happen before. The init order is guaranteed by CustomerIO.initialize() : createInstance() runs the constructor (which triggers init → registers DataPipeline), then module.initialize() is called in the loop afterwards. So SDKComponent.getOrNull<DataPipeline>() in ModuleLocation.initialize() always finds the registered instance.

* This guards against race conditions during app startup or incorrect call order.
*/
val locationServices: LocationServices
get() = _locationServices ?: UninitializedLocationServices(SDKComponent.logger)
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 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.

It's safe in practice, _locationServices is written once during initialize() (main thread, during SDK startup) before any host app code can access the getter. But it's not formally guaranteed across threads. Will add @Volatile for extra hardening.

@mahmoud-elmorabea mahmoud-elmorabea requested a review from a team February 25, 2026 13:12
@github-actions
Copy link

  • java_layout: chore-arch-revamp (1772028333)

@github-actions
Copy link

  • kotlin_compose: chore-arch-revamp (1772028362)

@codecov
Copy link

codecov bot commented Feb 25, 2026

Codecov Report

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

Files with missing lines Patch % Lines
...mer/datapipelines/plugins/IdentifyContextPlugin.kt 25.00% 14 Missing and 1 partial ⚠️
...customer/sdk/core/pipeline/IdentifyHookRegistry.kt 0.00% 7 Missing ⚠️
...tlin/io/customer/sdk/core/pipeline/IdentifyHook.kt 0.00% 1 Missing ⚠️
...ines/src/main/kotlin/io/customer/sdk/CustomerIO.kt 75.00% 1 Missing ⚠️
Additional details and impacted files
@@                    Coverage Diff                     @@
##             feat/real-time-location     #664   +/-   ##
==========================================================
  Coverage                           ?   67.41%           
  Complexity                         ?      764           
==========================================================
  Files                              ?      146           
  Lines                              ?     4382           
  Branches                           ?      591           
==========================================================
  Hits                               ?     2954           
  Misses                             ?     1201           
  Partials                           ?      227           

☔ 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

  • java_layout: chore-arch-revamp (1772040276)

@github-actions
Copy link

  • kotlin_compose: chore-arch-revamp (1772040292)

@github-actions
Copy link

  • java_layout: chore-arch-revamp (1772041705)

@github-actions
Copy link

  • kotlin_compose: chore-arch-revamp (1772041703)

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.


if (isChangingIdentifiedProfile) {
logger.info("changing profile from id $currentlyIdentifiedProfile to $userId")
locationSyncFilter.clearSyncedData()
Copy link

Choose a reason for hiding this comment

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

Sync filter not cleared on direct profile switch

High Severity

When switching directly from user A to user B (without clearIdentify), the old code called locationSyncFilter.clearSyncedData() inside the isChangingIdentifiedProfile block. That line was removed, and LocationTracker.resetContext() only runs during analytics.reset() (called in clearIdentifyImpl), not during direct profile switches. This means user B's location track events can be suppressed by user A's 24h/1km sync filter window. The PR description mentions "Profile switch detection via lastKnownUserId with sync filter reset" but lastKnownUserId does not exist in the implementation.

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.

The sync filter not clearing on direct profile switch is intentional.

When user B is identified, the IdentifyContextPlugin calls LocationTracker.getIdentifyContext() and attaches the current coordinates to request. This path is unfiltered, user B's profile receives the device's location immediately as part of the identify call itself.

The sync filter only gates the supplementary "Location Update" track event. If it suppresses that event because the same coordinates were recently sent, that's correct behavior — the backend already has the location from the identify event. No data is lost.

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.

Looks good! 💯

I do think we should go back to the approach where we exposed userId to detect a change. But do feel free to address this in this or later PR if we decide to go that way.

@mahmoud-elmorabea mahmoud-elmorabea requested a review from a team February 26, 2026 23:40
@Shahroz16 Shahroz16 merged commit 1f518e8 into feat/real-time-location Feb 27, 2026
36 of 37 checks passed
@Shahroz16 Shahroz16 deleted the chore-arch-revamp branch February 27, 2026 10:03
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