Skip to content

feat: expose getAnonymousID()#23

Merged
choudlet merged 9 commits into
mainfrom
chrish/sc-37838/feat-expose-getanonymousid-as-public
Apr 16, 2026
Merged

feat: expose getAnonymousID()#23
choudlet merged 9 commits into
mainfrom
chrish/sc-37838/feat-expose-getanonymousid-as-public

Conversation

@choudlet
Copy link
Copy Markdown
Collaborator

@choudlet choudlet commented Apr 15, 2026

Summary

Adds suspend fun getAnonymousId(): String to MetaRouter.Analytics, the public facade for SDK consumers. Suspends until the SDK is initialized and ready, then returns the anonymous ID — no null checks, no timing concerns.

Ticket: https://app.shortcut.com/metarouter/story/37838

Design decisions

  • suspend, not synchronous: consumers call MetaRouter.Analytics.getAnonymousId() and it suspends until the SDK is bound. If already initialized, returns immediately. This eliminates timing bugs from the fire-and-forget initialize() path.
  • Not on AnalyticsInterface: getAnonymousId() is a read of internal state, not a fire-and-forget action like track() or identify(). It doesn't belong on the interface the proxy implements — the proxy can't meaningfully queue or replay a synchronous read.
  • Not on AnalyticsProxy: the proxy handles fire-and-forget calls. Reads that need a return value go through the facade, which has the boundSignal to await readiness.
  • CompletableDeferred for the await mechanism: idiomatic one-shot coroutine signal. Zero overhead after completion, all subsequent await() calls return immediately. Reset on SDK reset.
  • Non-nullable return: no null, no retry. Either you get the ID or you get an IllegalStateException (which means you forgot to call initialize()).
  • IdentityManager unchanged: the eager UUID generation during init is preserved as-is.

Test plan

  • getAnonymousId returns value when ready — initialized client returns a non-blank UUID
  • getAnonymousId returns stable value across calls — same ID on consecutive calls
  • getAnonymousId throws after reset — lifecycle-aware throw behavior
  • getAnonymousId throws if not initialized — guard against missing initialize() call
  • getAnonymousId returns value after initializeAndWait — facade suspend path
  • getAnonymousId returns stable value across calls (facade) — consistency through facade
  • getAnonymousId throws after resetAndWait (facade) — reset clears bound signal
  • Full ./gradlew :metarouter-sdk:testDebugUnitTest passes

Add a synchronous `getAnonymousId(): String?` method to the public
analytics surface so SDK consumers can read the current anonymous ID.

- AnalyticsInterface: new method with KDoc
- MetaRouterAnalyticsClient: returns identityManager value when READY,
  null otherwise
- AnalyticsProxy: forwards to bound client, returns null when unbound
  (read-only, not queued as a pending call)
- Tests for concrete client (ready, stable, post-reset) and proxy
  (unbound-null, bound-forwarding, no-enqueue)
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 15, 2026

Test Results

 58 files  ± 0   58 suites  ±0   1m 3s ⏱️ +2s
463 tests + 7  463 ✅ + 7  0 💤 ±0  0 ❌ ±0 
926 runs  +14  926 ✅ +14  0 💤 ±0  0 ❌ ±0 

Results for commit 3d80bdb. ± Comparison against base commit 884a268.

♻️ This comment has been updated with latest results.

@choudlet choudlet changed the title feat: anon_id [android] feat: expose getAnonymousId as suspend on MetaRouter.Analytics Apr 15, 2026
@choudlet choudlet changed the title feat: expose getAnonymousId as suspend on MetaRouter.Analytics feat: expose getAnonymousID() Apr 15, 2026
Copy link
Copy Markdown

@brandon-metarouter brandon-metarouter left a comment

Choose a reason for hiding this comment

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

Few take it or leave its!


/** Signal that completes when the real client is bound to the proxy. */
@Volatile
private var boundSignal = CompletableDeferred<Unit>()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude proxying on this one:


If createAnalyticsClient is called fire + forget style and bg coroutine fails, boundSignal is never completed

Possible fix:

scope.launch {
      try {                                                                                                                              
          initializeInternal(context, options)                             
      } catch (e: Exception) {                
          Logger.error("Background initialization failed: ${e.message}")
          initializationStarted.set(false)                              
          // boundSignal is never completed or cancelled
          //  Fix: Complete boundSignal exceptionally in the catch block:     
          boundSignal.completeExceptionally(e)   
      }                                                                                                                                  
  }    
                                                                                                                                         
  Fix: Complete boundSignal exceptionally in the catch block:              
  boundSignal.completeExceptionally(e)   

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this is a great and valid callout thank you!

Comment on lines +204 to +212
suspend fun getAnonymousId(): String {
if (!initializationStarted.get()) {
throw IllegalStateException(
"MetaRouter not initialized. Call MetaRouter.Analytics.initialize() first."
)
}
boundSignal.await()
return store.get()!!.getAnonymousId()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Another claude proxy! I was actually reading about this in Go for atomic vs mutex, so it semi-made sense. Take it or leave it.


  • getAnonymousId() does two non-atomic reads: initializationStarted.get() then boundSignal.await()
  • initMutex can fix to do both

Claude says something like (heavily recommending you have claude double check this, don't take it as matter of fact).

suspend fun getAnonymousId(): String {                                                                                                 
      val signal: CompletableDeferred<Unit>
                                                                                                                                         
      initMutex.withLock {                                                 
          if (!initializationStarted.get()) {
              throw IllegalStateException(                                                                                               
                  "MetaRouter not initialized. Call MetaRouter.Analytics.initialize() first."
              )                                                                                                                          
          }                                                                
          signal = boundSignal  // capture the current reference under the same lock reset uses
      }                                                                                                                                  
  
      signal.await()                                                                                                                     
                                                                           
      val client = store.get()                                                                                                           
          ?: throw IllegalStateException("SDK bound signal completed but client store is empty")
      return client.getAnonymousId()                                                                                                     
  } 

And a "thank you for your patience" I really do not like leaving "claude says..." as PR review comments, but leveraging the review skill after human review because I don't know kotlin that well.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ended up making this change and it is a bit better organized.

proxy.unbind()

// Reset bound signal for next initialization
boundSignal = CompletableDeferred()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💪

@choudlet choudlet merged commit 3f3ce7f into main Apr 16, 2026
3 checks passed
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