Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ The analytics client provides the following methods:
- `alias(newUserId: String)`: Connect anonymous users to known user IDs. See [Using the alias() Method](#using-the-alias-method) for details
- `setAdvertisingId(advertisingId: String)`: Set the Google Advertising ID (GAID) for ad tracking. See [Advertising ID](#advertising-id-gaid) section for usage and compliance requirements
- `clearAdvertisingId()`: Clear the advertising identifier from storage and context. Useful for GDPR/CCPA compliance when users opt out of ad tracking
- `MetaRouter.Analytics.getAnonymousId(): String` (suspend): Retrieve the current anonymous ID. Suspends until the SDK is initialized and ready, then returns immediately on subsequent calls.
- `setTracing(enabled: Boolean)`: Enable or disable tracing headers on API requests. When enabled, includes a `Trace: true` header for debugging request flows
- `flush()`: Flush events immediately (suspending)
- `reset()`: Reset analytics state and clear all stored data (suspending). Also available as fire-and-forget via `MetaRouter.Analytics.reset()`
Expand Down Expand Up @@ -397,6 +398,13 @@ The `anonymousId` is a unique identifier automatically generated for each device
- Remains stable across app sessions until `reset()` is called
- Cleared on `reset()` and a **new** UUID is generated on next `initialize()`

**Reading the current value:**

```kotlin
val anonId = MetaRouter.Analytics.getAnonymousId()
// Suspends until the SDK is ready, then returns the anonymous ID
```

**Use case:**
Track user behavior before they log in or create an account, then connect pre-login and post-login activity using the `alias()` method.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.metarouter.analytics
import android.content.Context
import com.metarouter.analytics.lifecycle.AppLifecycleObserver
import com.metarouter.analytics.utils.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -30,6 +31,10 @@ object MetaRouter {
/** Atomic flag to ensure only one initialization attempt proceeds. */
private val initializationStarted = AtomicBoolean(false)

/** 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!


@Volatile
private var lifecycleObserver: AppLifecycleObserver? = null

Expand Down Expand Up @@ -57,7 +62,11 @@ object MetaRouter {
initializeInternal(context, options)
} catch (e: Exception) {
Logger.error("Background initialization failed: ${e.message}")
// Reset flag to allow retry
// Wake current awaiters with failure, then swap in a fresh
// signal so a retry's later boundSignal.complete(Unit) isn't
// swallowed by an already-completed deferred.
boundSignal.completeExceptionally(e)
boundSignal = CompletableDeferred()
initializationStarted.set(false)
}
}
Expand Down Expand Up @@ -115,6 +124,7 @@ object MetaRouter {
lifecycleObserver?.register()

proxy.bind(client)
boundSignal.complete(Unit)
}
}

Expand Down Expand Up @@ -185,6 +195,39 @@ object MetaRouter {
resetInternal()
}

/**
* Get the current anonymous ID, suspending until the SDK is ready.
*
* If initialization is still in progress, this suspends until the
* client is bound and ready, then returns the anonymous ID.
* If already initialized, returns immediately.
*
* @return The current anonymous ID
* @throws IllegalStateException if MetaRouter has not been initialized
*/
suspend fun getAnonymousId(): String {
// Capture the current boundSignal reference under the same lock
// reset uses, so we can't observe a mid-reset state where the flag
// is still true but boundSignal has already been swapped for a
// fresh (never-completing) deferred.
val signal: CompletableDeferred<Unit> = initMutex.withLock {
if (!initializationStarted.get()) {
throw IllegalStateException(
"MetaRouter not initialized. Call MetaRouter.Analytics.initialize() first."
)
}
boundSignal
}

signal.await()

val client = store.get()
?: throw IllegalStateException(
"MetaRouter bound signal completed but client store is empty (likely reset during getAnonymousId)"
)
return client.getAnonymousId()
}
Comment on lines +208 to +229

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.


/**
* Enable or disable debug logging.
*
Expand Down Expand Up @@ -215,6 +258,9 @@ object MetaRouter {
// Reset the proxy so it can be bound to a new client
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.

💪


// Reset initialization flag
initializationStarted.set(false)

Expand All @@ -236,6 +282,7 @@ object MetaRouter {
lifecycleObserver = null
store.clear()
proxy.resetForTesting()
boundSignal = CompletableDeferred()
initializationStarted.set(false)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,15 @@ class MetaRouterAnalyticsClient private constructor(
}
}

// ===== Identity Read Methods =====

fun getAnonymousId(): String {
check(lifecycleState.get() == LifecycleState.READY) {
"Cannot get anonymousId - SDK not ready (state: ${lifecycleState.get()})"
}
return identityManager.getAnonymousId()
}

// ===== Debug Methods =====

override fun enableDebugLogging() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.metarouter.analytics

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.metarouter.analytics.identity.IdentityManager
import com.metarouter.analytics.network.FakeNetworkMonitor
import com.metarouter.analytics.types.EventType
import io.mockk.*
Expand Down Expand Up @@ -332,6 +334,50 @@ class MetaRouterAnalyticsClientTest {
assertTrue((debugInfo["queueLength"] as Int) > 0)
}

// ===== getAnonymousId =====

@Test
fun `getAnonymousId returns value when ready`() = runBlocking {
val realContext: Context = ApplicationProvider.getApplicationContext()
val identityManager = IdentityManager(realContext)
val client = MetaRouterAnalyticsClient.initialize(
context, options, identityManager = identityManager
)

val anonymousId = client.getAnonymousId()
assertTrue(anonymousId.isNotBlank())
}

@Test
fun `getAnonymousId returns stable value across calls`() = runBlocking {
val realContext: Context = ApplicationProvider.getApplicationContext()
val identityManager = IdentityManager(realContext)
val client = MetaRouterAnalyticsClient.initialize(
context, options, identityManager = identityManager
)

val id1 = client.getAnonymousId()
val id2 = client.getAnonymousId()
assertEquals(id1, id2)
}

@Test
fun `getAnonymousId throws after reset`() = runBlocking {
val realContext: Context = ApplicationProvider.getApplicationContext()
val identityManager = IdentityManager(realContext)
val client = MetaRouterAnalyticsClient.initialize(
context, options, identityManager = identityManager
)
client.getAnonymousId() // should not throw

client.reset()

assertThrows(IllegalStateException::class.java) {
client.getAnonymousId()
}
Unit
}

// ===== Reset =====

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,50 @@ class MetaRouterTest {
assertFalse(Logger.debugEnabled)
}

// ===== getAnonymousId =====

@Test
fun `getAnonymousId throws if not initialized`() = runTest {
try {
MetaRouter.Analytics.getAnonymousId()
fail("Expected IllegalStateException")
} catch (e: IllegalStateException) {
// Expected
}
}

@Test
fun `getAnonymousId returns value after initializeAndWait`() = runTest {
MetaRouter.initializeAndWait(context, options)

val anonId = MetaRouter.Analytics.getAnonymousId()
assertNotNull(anonId)
}

@Test
fun `getAnonymousId returns stable value across calls`() = runTest {
MetaRouter.initializeAndWait(context, options)

val id1 = MetaRouter.Analytics.getAnonymousId()
val id2 = MetaRouter.Analytics.getAnonymousId()
assertEquals(id1, id2)
}

@Test
fun `getAnonymousId throws after resetAndWait`() = runTest {
MetaRouter.initializeAndWait(context, options)
MetaRouter.Analytics.getAnonymousId() // should work

MetaRouter.Analytics.resetAndWait()

try {
MetaRouter.Analytics.getAnonymousId()
fail("Expected IllegalStateException")
} catch (e: IllegalStateException) {
// Expected
}
}

// ===== Reset =====

@Test
Expand Down
Loading