feat: expose getAnonymousID()#23
Conversation
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)
brandon-metarouter
left a comment
There was a problem hiding this comment.
Few take it or leave its!
|
|
||
| /** Signal that completes when the real client is bound to the proxy. */ | ||
| @Volatile | ||
| private var boundSignal = CompletableDeferred<Unit>() |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
this is a great and valid callout thank you!
| suspend fun getAnonymousId(): String { | ||
| if (!initializationStarted.get()) { | ||
| throw IllegalStateException( | ||
| "MetaRouter not initialized. Call MetaRouter.Analytics.initialize() first." | ||
| ) | ||
| } | ||
| boundSignal.await() | ||
| return store.get()!!.getAnonymousId() | ||
| } |
There was a problem hiding this comment.
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()thenboundSignal.await()initMutexcan 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.
There was a problem hiding this comment.
Ended up making this change and it is a bit better organized.
| proxy.unbind() | ||
|
|
||
| // Reset bound signal for next initialization | ||
| boundSignal = CompletableDeferred() |
Summary
Adds
suspend fun getAnonymousId(): StringtoMetaRouter.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 callMetaRouter.Analytics.getAnonymousId()and it suspends until the SDK is bound. If already initialized, returns immediately. This eliminates timing bugs from the fire-and-forgetinitialize()path.AnalyticsInterface:getAnonymousId()is a read of internal state, not a fire-and-forget action liketrack()oridentify(). It doesn't belong on the interface the proxy implements — the proxy can't meaningfully queue or replay a synchronous read.AnalyticsProxy: the proxy handles fire-and-forget calls. Reads that need a return value go through the facade, which has theboundSignalto await readiness.CompletableDeferredfor the await mechanism: idiomatic one-shot coroutine signal. Zero overhead after completion, all subsequentawait()calls return immediately. Reset on SDK reset.IllegalStateException(which means you forgot to callinitialize()).Test plan
getAnonymousIdreturns value when ready — initialized client returns a non-blank UUIDgetAnonymousIdreturns stable value across calls — same ID on consecutive callsgetAnonymousIdthrows after reset — lifecycle-aware throw behaviorgetAnonymousIdthrows if not initialized — guard against missinginitialize()callgetAnonymousIdreturns value afterinitializeAndWait— facade suspend pathgetAnonymousIdreturns stable value across calls (facade) — consistency through facadegetAnonymousIdthrows afterresetAndWait(facade) — reset clears bound signal./gradlew :metarouter-sdk:testDebugUnitTestpasses