From d4ceef49af88d448da30ca26ac0f57cedc3b261a Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Tue, 3 Mar 2026 11:36:43 +0530 Subject: [PATCH] fix(android): trigger session start event reliably Fixes a bug where session start was not being called when a new session was started on app coming to foreground --- .../sh/measure/android/MeasureInternal.kt | 32 +++++++-------- .../java/sh/measure/android/SessionManager.kt | 23 ++++++++++- .../sh/measure/android/MeasureInternalTest.kt | 32 ++++++++++++++- .../sh/measure/android/SessionManagerTest.kt | 41 +++++++++++++++++++ .../android/fakes/FakeSessionManager.kt | 6 +++ 5 files changed, 115 insertions(+), 19 deletions(-) diff --git a/android/measure/src/main/java/sh/measure/android/MeasureInternal.kt b/android/measure/src/main/java/sh/measure/android/MeasureInternal.kt index 47ab6b1dc..45509d524 100644 --- a/android/measure/src/main/java/sh/measure/android/MeasureInternal.kt +++ b/android/measure/src/main/java/sh/measure/android/MeasureInternal.kt @@ -16,7 +16,9 @@ import sh.measure.android.utils.AttachmentHelper /** * Initializes the Measure SDK and hides the internal dependencies from public API. */ -internal class MeasureInternal(private val measure: MeasureInitializer) : AppLifecycleListener { +internal class MeasureInternal(private val measure: MeasureInitializer) : + AppLifecycleListener, + SessionStartListener { val timeProvider = measure.timeProvider val processInfoProvider = measure.processInfoProvider val logger = measure.logger @@ -33,21 +35,9 @@ internal class MeasureInternal(private val measure: MeasureInitializer) : AppLif return } + measure.sessionManager.setSessionStartListener(this) // start a session - val sessionId = measure.sessionManager.init() - - // All events are processed on a single thread in a queue. - // So, the first event will always be a session start event - // as we initialize all other collectors after this event - // is triggered. - measure.signalProcessor.track( - SessionStartData, - timestamp = measure.sessionManager.getSessionStartTime(), - type = EventType.SESSION_START, - sessionId = sessionId, - // always sample session start event - isSampled = true, - ) + measure.sessionManager.init() // setup lifecycle state measure.resumedActivityProvider.register() @@ -127,6 +117,17 @@ internal class MeasureInternal(private val measure: MeasureInitializer) : AppLif } } + override fun onSessionStart(sessionId: String, startTime: Long) { + measure.signalProcessor.track( + SessionStartData, + timestamp = startTime, + type = EventType.SESSION_START, + sessionId = sessionId, + // always sample session start event + isSampled = true, + ) + } + fun setUserId(userId: String) { measure.userAttributeProcessor.setUserId(userId) } @@ -392,6 +393,5 @@ internal class MeasureInternal(private val measure: MeasureInitializer) : AppLif measure.unhandledExceptionCollector.unregister() measure.anrCollector.unregister() } - fun getDynamicConfigPath(): String? = measure.fileStorage.getConfigPath() } diff --git a/android/measure/src/main/java/sh/measure/android/SessionManager.kt b/android/measure/src/main/java/sh/measure/android/SessionManager.kt index eec644b29..288d11c7a 100644 --- a/android/measure/src/main/java/sh/measure/android/SessionManager.kt +++ b/android/measure/src/main/java/sh/measure/android/SessionManager.kt @@ -17,6 +17,13 @@ import sh.measure.android.utils.Sampler import sh.measure.android.utils.TimeProvider import java.util.concurrent.RejectedExecutionException +/** + * Callback to be called when a new session is created. + */ +internal interface SessionStartListener { + fun onSessionStart(sessionId: String, startTime: Long) +} + internal interface SessionManager { /** * Creates a new session, to be used only when the SDK is initialized. @@ -57,6 +64,13 @@ internal interface SessionManager { * Called when config is loaded. */ fun onConfigLoaded() + + /** + * Sets a listener to be called when a new session is created. + * + * @param listener the listener to be called + */ + fun setSessionStartListener(listener: SessionStartListener) } /** @@ -76,6 +90,7 @@ internal class SessionManagerImpl( private val packageInfoProvider: PackageInfoProvider, private val sampler: Sampler, ) : SessionManager { + private var sessionStartListener: SessionStartListener? = null private var sessionId: String? = null private var sessionStartTime: Long? = null @@ -151,10 +166,16 @@ internal class SessionManagerImpl( } } + override fun setSessionStartListener(listener: SessionStartListener) { + this.sessionStartListener = listener + } + private fun createNewSession(): String { val id = idProvider.uuid() + val startTime = timeProvider.now() this.sessionId = id - this.sessionStartTime = timeProvider.now() + this.sessionStartTime = startTime + sessionStartListener?.onSessionStart(id, startTime) storeSession(id) return id } diff --git a/android/measure/src/test/java/sh/measure/android/MeasureInternalTest.kt b/android/measure/src/test/java/sh/measure/android/MeasureInternalTest.kt index 0f7f80d3e..b1f96a51a 100644 --- a/android/measure/src/test/java/sh/measure/android/MeasureInternalTest.kt +++ b/android/measure/src/test/java/sh/measure/android/MeasureInternalTest.kt @@ -6,26 +6,34 @@ import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.`when` import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import sh.measure.android.config.DynamicConfig +import sh.measure.android.events.EventType +import sh.measure.android.events.SignalProcessor +import sh.measure.android.fakes.FakeSessionManager import sh.measure.android.utils.ManifestMetadata class MeasureInternalTest { + private val sessionManager = mock() + private val signalProcessor = mock() + private fun mockMeasureInitializer(): MeasureInitializer { val initializer = mock(MeasureInitializer::class.java) // Stubbing all fields with mocks `when`(initializer.logger).thenReturn(mock()) - `when`(initializer.signalProcessor).thenReturn(mock()) + `when`(initializer.signalProcessor).thenReturn(signalProcessor) `when`(initializer.httpEventCollector).thenReturn(mock()) `when`(initializer.processInfoProvider).thenReturn(mock()) `when`(initializer.timeProvider).thenReturn(mock()) `when`(initializer.bugReportCollector).thenReturn(mock()) `when`(initializer.spanCollector).thenReturn(mock()) `when`(initializer.customEventCollector).thenReturn(mock()) - `when`(initializer.sessionManager).thenReturn(mock()) + `when`(initializer.sessionManager).thenReturn(sessionManager) `when`(initializer.userTriggeredEventCollector).thenReturn(mock()) `when`(initializer.resumedActivityProvider).thenReturn(mock()) `when`(initializer.networkClient).thenReturn(mock()) @@ -368,6 +376,26 @@ class MeasureInternalTest { verify(initializer.unhandledExceptionCollector, never()).unregister() } + @Test + fun `session start callback triggers a session start event`() { + val initializer = mockMeasureInitializer() + val measureInternal = MeasureInternal(initializer) + measureInternal.onSessionStart("session-id", 123456789L) + + verify(signalProcessor).track( + data = eq(SessionStartData), + timestamp = eq(123456789L), + type = eq(EventType.SESSION_START), + attributes = any(), + userDefinedAttributes = any(), + attachments = any(), + threadName = anyOrNull(), + sessionId = eq("session-id"), + userTriggered = any(), + isSampled = eq(true), + ) + } + private fun initWithValidCredentials(): MeasureInitializer { val initializer = mockMeasureInitializer() val manifest = ManifestMetadata("https://api.measure.sh", "msrsh_123") diff --git a/android/measure/src/test/java/sh/measure/android/SessionManagerTest.kt b/android/measure/src/test/java/sh/measure/android/SessionManagerTest.kt index 9962e680b..7e81ecac9 100644 --- a/android/measure/src/test/java/sh/measure/android/SessionManagerTest.kt +++ b/android/measure/src/test/java/sh/measure/android/SessionManagerTest.kt @@ -204,4 +204,45 @@ class SessionManagerTest { verify(database, never()).sampleJourneyEvents(any(), any()) } + + @Test + fun `onSessionStart listener is invoked on init`() { + var callbackSessionId: String? = null + var callbackSessionTime: Long? = null + sessionManager.setSessionStartListener(object : SessionStartListener { + override fun onSessionStart(sessionId: String, startTime: Long) { + callbackSessionId = sessionId + callbackSessionTime = startTime + } + }) + + sessionManager.init() + + assertEquals(sessionManager.getSessionId(), callbackSessionId) + assertEquals(sessionManager.getSessionStartTime(), callbackSessionTime) + } + + @Test + fun `onSessionStart listener is invoked on new session when app comes back to foreground`() { + var callbackSessionId: String? = null + var callbackSessionTime: Long? = null + sessionManager.setSessionStartListener(object : SessionStartListener { + override fun onSessionStart(sessionId: String, startTime: Long) { + callbackSessionId = sessionId + callbackSessionTime = startTime + } + }) + + sessionManager.init() + sessionManager.onAppBackground() + + testClock.advance(Duration.ofMillis(configProvider.sessionBackgroundTimeoutThresholdMs + 1)) + val updatedSessionId = "next-uuid" + idProvider.id = updatedSessionId + + sessionManager.onAppForeground() + + assertEquals(updatedSessionId, callbackSessionId) + assertEquals(sessionManager.getSessionStartTime(), callbackSessionTime) + } } diff --git a/android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt b/android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt index 4d3701c9c..eda0d920e 100644 --- a/android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt +++ b/android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt @@ -1,10 +1,12 @@ package sh.measure.android.fakes import sh.measure.android.SessionManager +import sh.measure.android.SessionStartListener internal class FakeSessionManager : SessionManager { var id = "fake-session-id" var startTime: Long = 0L + var onSessionStartListener: SessionStartListener? = null override fun init(): String = id @@ -23,4 +25,8 @@ internal class FakeSessionManager : SessionManager { override fun onConfigLoaded() { // no-op } + + override fun setSessionStartListener(listener: SessionStartListener) { + onSessionStartListener = listener + } }