Make 'room list catch-up' analytics transaction network aware (#6233)

* Make 'room list catch-up' analytics transaction network aware.
* Add `RoomListService.isInitialSyncDone`. Use this to simplify `DefaultAnalyticsRoomListStateWatcher`'s logic.
This commit is contained in:
Jorge Martin Espinosa
2026-03-03 13:16:58 +01:00
committed by GitHub
parent 1f69958dab
commit 70d5e1868a
9 changed files with 91 additions and 38 deletions

View File

@@ -35,6 +35,11 @@ interface RoomListService {
data object Hide : SyncIndicator data object Hide : SyncIndicator
} }
/**
* Indicates whether the initial sliding sync request is done or not.
*/
val isInitialSyncDone: Boolean
/** /**
* Creates a room list that can be used to load more rooms and filter them dynamically. * Creates a room list that can be used to load more rooms and filter them dynamically.
* @param pageSize the number of rooms to load at once. * @param pageSize the number of rooms to load at once.

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.stateIn
import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
internal class RustRoomListService( internal class RustRoomListService(
@@ -33,6 +34,9 @@ internal class RustRoomListService(
private val roomSyncSubscriber: RoomSyncSubscriber, private val roomSyncSubscriber: RoomSyncSubscriber,
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
) : RoomListService { ) : RoomListService {
private val _isInitialSyncDone = AtomicBoolean(false)
override val isInitialSyncDone: Boolean get() = _isInitialSyncDone.get()
override fun createRoomList( override fun createRoomList(
pageSize: Int, pageSize: Int,
source: RoomList.Source, source: RoomList.Source,
@@ -75,6 +79,9 @@ internal class RustRoomListService(
.map { it.toRoomListState() } .map { it.toRoomListState() }
.onEach { state -> .onEach { state ->
Timber.d("RoomList state=$state") Timber.d("RoomList state=$state")
if (state == RoomListService.State.Running) {
_isInitialSyncDone.set(true)
}
} }
.distinctUntilChanged() .distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.State.Idle) .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.State.Idle)

View File

@@ -20,10 +20,14 @@ class FakeRoomListService(
private val subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {}, private val subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {},
private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) }, private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) },
override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE), override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE),
private val isInitialSyncLambda: () -> Boolean = { true },
) : RoomListService { ) : RoomListService {
private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle) private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle)
private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide) private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide)
override val isInitialSyncDone: Boolean
get() = isInitialSyncLambda()
suspend fun postState(state: RoomListService.State) { suspend fun postState(state: RoomListService.State) {
roomListStateFlow.emit(state) roomListStateFlow.emit(state)
} }

View File

@@ -8,8 +8,11 @@
package io.element.android.services.analytics.api package io.element.android.services.analytics.api
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
object NoopAnalyticsTransaction : AnalyticsTransaction { object NoopAnalyticsTransaction : AnalyticsTransaction {
override val duration: Duration = 0.seconds
override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction
override fun putExtraData(key: String, value: String) {} override fun putExtraData(key: String, value: String) {}
override fun putIndexableData(key: String, value: String) {} override fun putIndexableData(key: String, value: String) {}

View File

@@ -26,6 +26,7 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
@@ -37,6 +38,7 @@ dependencies {
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.analyticsproviders.test) testImplementation(projects.services.analyticsproviders.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)

View File

@@ -8,29 +8,32 @@
package io.element.android.services.analytics.impl.watchers package io.element.android.services.analytics.impl.watchers
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analytics.api.watchers.AnalyticsRoomListStateWatcher import io.element.android.services.analytics.api.watchers.AnalyticsRoomListStateWatcher
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.minutes
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultAnalyticsRoomListStateWatcher( class DefaultAnalyticsRoomListStateWatcher(
private val appNavigationStateService: AppNavigationStateService, private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor,
private val roomListService: RoomListService, private val roomListService: RoomListService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
@@ -38,7 +41,7 @@ class DefaultAnalyticsRoomListStateWatcher(
) : AnalyticsRoomListStateWatcher { ) : AnalyticsRoomListStateWatcher {
private val coroutineScope: CoroutineScope = sessionCoroutineScope.childScope(dispatchers.computation, "AnalyticsRoomListStateWatcher") private val coroutineScope: CoroutineScope = sessionCoroutineScope.childScope(dispatchers.computation, "AnalyticsRoomListStateWatcher")
private val isStarted = AtomicBoolean(false) private val isStarted = AtomicBoolean(false)
private val isWarmState = AtomicBoolean(false) private val isNotInitialSync get() = roomListService.isInitialSyncDone
override fun start() { override fun start() {
if (isStarted.getAndSet(true)) { if (isStarted.getAndSet(true)) {
@@ -48,27 +51,40 @@ class DefaultAnalyticsRoomListStateWatcher(
val longRunningTransaction = AnalyticsLongRunningTransaction.CatchUp val longRunningTransaction = AnalyticsLongRunningTransaction.CatchUp
appNavigationStateService.appNavigationState val hasNetworkConnectivityFlow = networkMonitor.connectivity
.map { it.isInForeground } .map { it == NetworkStatus.Connected }
.distinctUntilChanged() .distinctUntilChanged()
.withPreviousValue()
.onEach { (wasInForeground, isInForeground) ->
if (isInForeground && roomListService.state.value != RoomListService.State.Running) {
analyticsService.startLongRunningTransaction(longRunningTransaction)
} else if (!isInForeground) {
analyticsService.removeLongRunningTransaction(longRunningTransaction)
}
if (wasInForeground == false && isInForeground) { combine(
isWarmState.set(true) appForegroundStateService.isInForeground,
hasNetworkConnectivityFlow,
) { isInForeground, hasNetworkConnectivity ->
val canSync = isInForeground && hasNetworkConnectivity
val isNotSyncing = roomListService.state.value != RoomListService.State.Running
if (isNotInitialSync && canSync && isNotSyncing) {
Timber.d("Catch-up transaction: starting")
analyticsService.startLongRunningTransaction(longRunningTransaction)
} else if (!isInForeground || !hasNetworkConnectivity) {
analyticsService.removeLongRunningTransaction(longRunningTransaction)?.let {
Timber.d("Catch-up transaction: stopping")
}
} }
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
roomListService.state roomListService.state
.onEach { state -> .onEach { state ->
if (state == RoomListService.State.Running && isWarmState.get()) { if (state == RoomListService.State.Running && isNotInitialSync) {
analyticsService.finishLongRunningTransaction(longRunningTransaction) val transaction = analyticsService.removeLongRunningTransaction(longRunningTransaction)
if (transaction != null && !transaction.isFinished()) {
val duration = transaction.duration
if (duration > 3.minutes) {
Timber.d("Cancelling catch-up transaction, the elapsed time is too long ($duration), something probably went wrong while measuring")
} else {
Timber.d("Catch-up transaction finished in $duration")
transaction.finish()
}
}
} }
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)

View File

@@ -8,13 +8,12 @@
package io.element.android.services.analytics.impl.watchers package io.element.android.services.analytics.impl.watchers
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.CatchUp import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.CatchUp
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@@ -26,13 +25,13 @@ import org.junit.Test
class DefaultAnalyticsRoomListStateWatcherTest { class DefaultAnalyticsRoomListStateWatcherTest {
@Test @Test
fun `Opening the app in a warm state tracks the time until the room list is synced`() = runTest { fun `Opening the app in a warm state tracks the time until the room list is synced`() = runTest {
val navigationStateService = FakeAppNavigationStateService() val appForegroundStateService = FakeAppForegroundStateService()
val roomListService = FakeRoomListService().apply { val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Idle) postState(RoomListService.State.Idle)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsRoomListStateWatcher( val watcher = createAnalyticsRoomListStateWatcher(
appNavigationStateService = navigationStateService, appForegroundStateService = appForegroundStateService,
roomListService = roomListService, roomListService = roomListService,
analyticsService = analyticsService, analyticsService = analyticsService,
) )
@@ -43,9 +42,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent() runCurrent()
// Make sure it's warm by changing its internal state // Make sure it's warm by changing its internal state
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) appForegroundStateService.givenIsInForeground(false)
runCurrent() runCurrent()
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) appForegroundStateService.givenIsInForeground(true)
runCurrent() runCurrent()
// The transaction should be present now // The transaction should be present now
@@ -63,15 +62,15 @@ class DefaultAnalyticsRoomListStateWatcherTest {
@Test @Test
fun `Opening the app in a cold state does nothing`() = runTest { fun `Opening the app in a cold state does nothing`() = runTest {
val navigationStateService = FakeAppNavigationStateService( val appForegroundStateService = FakeAppForegroundStateService(
initialAppNavigationState = AppNavigationState(NavigationState.Root, false) initialForegroundValue = false
) )
val roomListService = FakeRoomListService().apply { val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Idle) postState(RoomListService.State.Idle)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsRoomListStateWatcher( val watcher = createAnalyticsRoomListStateWatcher(
appNavigationStateService = navigationStateService, appForegroundStateService = appForegroundStateService,
roomListService = roomListService, roomListService = roomListService,
analyticsService = analyticsService, analyticsService = analyticsService,
) )
@@ -93,13 +92,13 @@ class DefaultAnalyticsRoomListStateWatcherTest {
@Test @Test
fun `The transaction won't be finished until the room list is synchronised`() = runTest { fun `The transaction won't be finished until the room list is synchronised`() = runTest {
val navigationStateService = FakeAppNavigationStateService() val appForegroundStateService = FakeAppForegroundStateService()
val roomListService = FakeRoomListService().apply { val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Idle) postState(RoomListService.State.Idle)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsRoomListStateWatcher( val watcher = createAnalyticsRoomListStateWatcher(
appNavigationStateService = navigationStateService, appForegroundStateService = appForegroundStateService,
roomListService = roomListService, roomListService = roomListService,
analyticsService = analyticsService, analyticsService = analyticsService,
) )
@@ -110,9 +109,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent() runCurrent()
// Make sure it's warm by changing its internal state // Make sure it's warm by changing its internal state
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) appForegroundStateService.givenIsInForeground(false)
runCurrent() runCurrent()
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) appForegroundStateService.givenIsInForeground(true)
runCurrent() runCurrent()
// The transaction should be present now // The transaction should be present now
@@ -128,13 +127,13 @@ class DefaultAnalyticsRoomListStateWatcherTest {
@Test @Test
fun `Opening the app when the room list state was already Running does nothing`() = runTest { fun `Opening the app when the room list state was already Running does nothing`() = runTest {
val navigationStateService = FakeAppNavigationStateService() val appForegroundStateService = FakeAppForegroundStateService()
val roomListService = FakeRoomListService().apply { val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Running) postState(RoomListService.State.Running)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsRoomListStateWatcher( val watcher = createAnalyticsRoomListStateWatcher(
appNavigationStateService = navigationStateService, appForegroundStateService = appForegroundStateService,
roomListService = roomListService, roomListService = roomListService,
analyticsService = analyticsService, analyticsService = analyticsService,
) )
@@ -145,9 +144,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent() runCurrent()
// Make sure it's warm by changing its internal state // Make sure it's warm by changing its internal state
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) appForegroundStateService.givenIsInForeground(false)
runCurrent() runCurrent()
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) appForegroundStateService.givenIsInForeground(true)
runCurrent() runCurrent()
// The transaction was never added // The transaction was never added
@@ -157,14 +156,16 @@ class DefaultAnalyticsRoomListStateWatcherTest {
} }
private fun TestScope.createAnalyticsRoomListStateWatcher( private fun TestScope.createAnalyticsRoomListStateWatcher(
appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService(), appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
roomListService: FakeRoomListService = FakeRoomListService(), roomListService: FakeRoomListService = FakeRoomListService(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
) = DefaultAnalyticsRoomListStateWatcher( ) = DefaultAnalyticsRoomListStateWatcher(
appNavigationStateService = appNavigationStateService, appForegroundStateService = appForegroundStateService,
roomListService = roomListService, roomListService = roomListService,
analyticsService = analyticsService, analyticsService = analyticsService,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
networkMonitor = networkMonitor,
) )
} }

View File

@@ -7,7 +7,14 @@
package io.element.android.services.analyticsproviders.api package io.element.android.services.analyticsproviders.api
import kotlin.time.Duration
interface AnalyticsTransaction { interface AnalyticsTransaction {
/**
* The time elapsed since the transaction started until now if the transaction is ongoing or the time it finished.
*/
val duration: Duration
/** /**
* Start a child span from this transaction. * Start a child span from this transaction.
*/ */

View File

@@ -11,7 +11,10 @@ import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.sentry.ISpan import io.sentry.ISpan
import io.sentry.ITransaction import io.sentry.ITransaction
import io.sentry.Sentry import io.sentry.Sentry
import io.sentry.SentryInstantDate
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration
import kotlin.time.Duration.Companion.nanoseconds
class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction { class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction {
constructor(name: String, operation: String?, description: String? = null) : this( constructor(name: String, operation: String?, description: String? = null) : this(
@@ -19,6 +22,11 @@ class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTra
) )
private val inner = span private val inner = span
@Suppress("UnstableApiUsage")
override val duration: Duration get() {
return (inner.finishDate ?: SentryInstantDate()).diff(inner.startDate).nanoseconds
}
override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction( override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction(
inner.startChild(operation, description) inner.startChild(operation, description)
) )