Skip to content

Commit d4ceef4

Browse files
committed
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
1 parent 2d06940 commit d4ceef4

File tree

5 files changed

+115
-19
lines changed

5 files changed

+115
-19
lines changed

android/measure/src/main/java/sh/measure/android/MeasureInternal.kt

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import sh.measure.android.utils.AttachmentHelper
1616
/**
1717
* Initializes the Measure SDK and hides the internal dependencies from public API.
1818
*/
19-
internal class MeasureInternal(private val measure: MeasureInitializer) : AppLifecycleListener {
19+
internal class MeasureInternal(private val measure: MeasureInitializer) :
20+
AppLifecycleListener,
21+
SessionStartListener {
2022
val timeProvider = measure.timeProvider
2123
val processInfoProvider = measure.processInfoProvider
2224
val logger = measure.logger
@@ -33,21 +35,9 @@ internal class MeasureInternal(private val measure: MeasureInitializer) : AppLif
3335
return
3436
}
3537

38+
measure.sessionManager.setSessionStartListener(this)
3639
// start a session
37-
val sessionId = measure.sessionManager.init()
38-
39-
// All events are processed on a single thread in a queue.
40-
// So, the first event will always be a session start event
41-
// as we initialize all other collectors after this event
42-
// is triggered.
43-
measure.signalProcessor.track(
44-
SessionStartData,
45-
timestamp = measure.sessionManager.getSessionStartTime(),
46-
type = EventType.SESSION_START,
47-
sessionId = sessionId,
48-
// always sample session start event
49-
isSampled = true,
50-
)
40+
measure.sessionManager.init()
5141

5242
// setup lifecycle state
5343
measure.resumedActivityProvider.register()
@@ -127,6 +117,17 @@ internal class MeasureInternal(private val measure: MeasureInitializer) : AppLif
127117
}
128118
}
129119

120+
override fun onSessionStart(sessionId: String, startTime: Long) {
121+
measure.signalProcessor.track(
122+
SessionStartData,
123+
timestamp = startTime,
124+
type = EventType.SESSION_START,
125+
sessionId = sessionId,
126+
// always sample session start event
127+
isSampled = true,
128+
)
129+
}
130+
130131
fun setUserId(userId: String) {
131132
measure.userAttributeProcessor.setUserId(userId)
132133
}
@@ -392,6 +393,5 @@ internal class MeasureInternal(private val measure: MeasureInitializer) : AppLif
392393
measure.unhandledExceptionCollector.unregister()
393394
measure.anrCollector.unregister()
394395
}
395-
396396
fun getDynamicConfigPath(): String? = measure.fileStorage.getConfigPath()
397397
}

android/measure/src/main/java/sh/measure/android/SessionManager.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ import sh.measure.android.utils.Sampler
1717
import sh.measure.android.utils.TimeProvider
1818
import java.util.concurrent.RejectedExecutionException
1919

20+
/**
21+
* Callback to be called when a new session is created.
22+
*/
23+
internal interface SessionStartListener {
24+
fun onSessionStart(sessionId: String, startTime: Long)
25+
}
26+
2027
internal interface SessionManager {
2128
/**
2229
* Creates a new session, to be used only when the SDK is initialized.
@@ -57,6 +64,13 @@ internal interface SessionManager {
5764
* Called when config is loaded.
5865
*/
5966
fun onConfigLoaded()
67+
68+
/**
69+
* Sets a listener to be called when a new session is created.
70+
*
71+
* @param listener the listener to be called
72+
*/
73+
fun setSessionStartListener(listener: SessionStartListener)
6074
}
6175

6276
/**
@@ -76,6 +90,7 @@ internal class SessionManagerImpl(
7690
private val packageInfoProvider: PackageInfoProvider,
7791
private val sampler: Sampler,
7892
) : SessionManager {
93+
private var sessionStartListener: SessionStartListener? = null
7994
private var sessionId: String? = null
8095
private var sessionStartTime: Long? = null
8196

@@ -151,10 +166,16 @@ internal class SessionManagerImpl(
151166
}
152167
}
153168

169+
override fun setSessionStartListener(listener: SessionStartListener) {
170+
this.sessionStartListener = listener
171+
}
172+
154173
private fun createNewSession(): String {
155174
val id = idProvider.uuid()
175+
val startTime = timeProvider.now()
156176
this.sessionId = id
157-
this.sessionStartTime = timeProvider.now()
177+
this.sessionStartTime = startTime
178+
sessionStartListener?.onSessionStart(id, startTime)
158179
storeSession(id)
159180
return id
160181
}

android/measure/src/test/java/sh/measure/android/MeasureInternalTest.kt

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,34 @@ import org.mockito.Mockito.never
66
import org.mockito.Mockito.times
77
import org.mockito.Mockito.`when`
88
import org.mockito.kotlin.any
9+
import org.mockito.kotlin.anyOrNull
910
import org.mockito.kotlin.argumentCaptor
11+
import org.mockito.kotlin.eq
1012
import org.mockito.kotlin.verify
1113
import org.mockito.kotlin.whenever
1214
import sh.measure.android.config.DynamicConfig
15+
import sh.measure.android.events.EventType
16+
import sh.measure.android.events.SignalProcessor
17+
import sh.measure.android.fakes.FakeSessionManager
1318
import sh.measure.android.utils.ManifestMetadata
1419

1520
class MeasureInternalTest {
21+
private val sessionManager = mock<SessionManager>()
22+
private val signalProcessor = mock<SignalProcessor>()
23+
1624
private fun mockMeasureInitializer(): MeasureInitializer {
1725
val initializer = mock(MeasureInitializer::class.java)
1826

1927
// Stubbing all fields with mocks
2028
`when`(initializer.logger).thenReturn(mock())
21-
`when`(initializer.signalProcessor).thenReturn(mock())
29+
`when`(initializer.signalProcessor).thenReturn(signalProcessor)
2230
`when`(initializer.httpEventCollector).thenReturn(mock())
2331
`when`(initializer.processInfoProvider).thenReturn(mock())
2432
`when`(initializer.timeProvider).thenReturn(mock())
2533
`when`(initializer.bugReportCollector).thenReturn(mock())
2634
`when`(initializer.spanCollector).thenReturn(mock())
2735
`when`(initializer.customEventCollector).thenReturn(mock())
28-
`when`(initializer.sessionManager).thenReturn(mock())
36+
`when`(initializer.sessionManager).thenReturn(sessionManager)
2937
`when`(initializer.userTriggeredEventCollector).thenReturn(mock())
3038
`when`(initializer.resumedActivityProvider).thenReturn(mock())
3139
`when`(initializer.networkClient).thenReturn(mock())
@@ -368,6 +376,26 @@ class MeasureInternalTest {
368376
verify(initializer.unhandledExceptionCollector, never()).unregister()
369377
}
370378

379+
@Test
380+
fun `session start callback triggers a session start event`() {
381+
val initializer = mockMeasureInitializer()
382+
val measureInternal = MeasureInternal(initializer)
383+
measureInternal.onSessionStart("session-id", 123456789L)
384+
385+
verify(signalProcessor).track(
386+
data = eq(SessionStartData),
387+
timestamp = eq(123456789L),
388+
type = eq(EventType.SESSION_START),
389+
attributes = any(),
390+
userDefinedAttributes = any(),
391+
attachments = any(),
392+
threadName = anyOrNull(),
393+
sessionId = eq("session-id"),
394+
userTriggered = any(),
395+
isSampled = eq(true),
396+
)
397+
}
398+
371399
private fun initWithValidCredentials(): MeasureInitializer {
372400
val initializer = mockMeasureInitializer()
373401
val manifest = ManifestMetadata("https://api.measure.sh", "msrsh_123")

android/measure/src/test/java/sh/measure/android/SessionManagerTest.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,45 @@ class SessionManagerTest {
204204

205205
verify(database, never()).sampleJourneyEvents(any(), any())
206206
}
207+
208+
@Test
209+
fun `onSessionStart listener is invoked on init`() {
210+
var callbackSessionId: String? = null
211+
var callbackSessionTime: Long? = null
212+
sessionManager.setSessionStartListener(object : SessionStartListener {
213+
override fun onSessionStart(sessionId: String, startTime: Long) {
214+
callbackSessionId = sessionId
215+
callbackSessionTime = startTime
216+
}
217+
})
218+
219+
sessionManager.init()
220+
221+
assertEquals(sessionManager.getSessionId(), callbackSessionId)
222+
assertEquals(sessionManager.getSessionStartTime(), callbackSessionTime)
223+
}
224+
225+
@Test
226+
fun `onSessionStart listener is invoked on new session when app comes back to foreground`() {
227+
var callbackSessionId: String? = null
228+
var callbackSessionTime: Long? = null
229+
sessionManager.setSessionStartListener(object : SessionStartListener {
230+
override fun onSessionStart(sessionId: String, startTime: Long) {
231+
callbackSessionId = sessionId
232+
callbackSessionTime = startTime
233+
}
234+
})
235+
236+
sessionManager.init()
237+
sessionManager.onAppBackground()
238+
239+
testClock.advance(Duration.ofMillis(configProvider.sessionBackgroundTimeoutThresholdMs + 1))
240+
val updatedSessionId = "next-uuid"
241+
idProvider.id = updatedSessionId
242+
243+
sessionManager.onAppForeground()
244+
245+
assertEquals(updatedSessionId, callbackSessionId)
246+
assertEquals(sessionManager.getSessionStartTime(), callbackSessionTime)
247+
}
207248
}

android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package sh.measure.android.fakes
22

33
import sh.measure.android.SessionManager
4+
import sh.measure.android.SessionStartListener
45

56
internal class FakeSessionManager : SessionManager {
67
var id = "fake-session-id"
78
var startTime: Long = 0L
9+
var onSessionStartListener: SessionStartListener? = null
810

911
override fun init(): String = id
1012

@@ -23,4 +25,8 @@ internal class FakeSessionManager : SessionManager {
2325
override fun onConfigLoaded() {
2426
// no-op
2527
}
28+
29+
override fun setSessionStartListener(listener: SessionStartListener) {
30+
onSessionStartListener = listener
31+
}
2632
}

0 commit comments

Comments
 (0)