Adjust metrics to the new specifications (#5937)
* Add `AnalyticsTransactions` with a set of `TransactionDefinition` items matching those in the user story * Use that for `AnalyticsLongRunningTransactions`, make sure we send the right fields (name, operation, description) * Add `AnalyticsSendMessageWatcher` to track how long it takes for an event to be sent and for us to get a call back for that from sync * Add `Noop` implementation for enterprise
This commit is contained in:
committed by
GitHub
parent
bc62d4c8ba
commit
71031008dd
@@ -63,6 +63,7 @@ dependencies {
|
|||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.libraries.pushproviders.test)
|
testImplementation(projects.libraries.pushproviders.test)
|
||||||
testImplementation(projects.features.forward.test)
|
testImplementation(projects.features.forward.test)
|
||||||
|
testImplementation(projects.features.messages.test)
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
testImplementation(projects.features.rageshake.test)
|
testImplementation(projects.features.rageshake.test)
|
||||||
testImplementation(projects.services.appnavstate.impl)
|
testImplementation(projects.services.appnavstate.impl)
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ class RootFlowNode(
|
|||||||
is ResolvedIntent.Navigation -> {
|
is ResolvedIntent.Navigation -> {
|
||||||
val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false)
|
val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false)
|
||||||
if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) {
|
if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) {
|
||||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline)
|
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationToMessage)
|
||||||
}
|
}
|
||||||
navigateTo(resolvedIntent.deeplinkData)
|
navigateTo(resolvedIntent.deeplinkData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ package io.element.android.appnav.di
|
|||||||
|
|
||||||
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
|
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
|
||||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||||
|
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
|
||||||
|
|
||||||
interface TimelineBindings {
|
interface TimelineBindings {
|
||||||
val timelineProvider: TimelineProvider
|
val timelineProvider: TimelineProvider
|
||||||
val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
|
val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
|
||||||
|
val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
|||||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||||
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
|
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
|
||||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
|
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
|
||||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
|
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
|
||||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
|
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -128,7 +128,7 @@ class RoomFlowNode(
|
|||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline)
|
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage)
|
||||||
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
|
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
|
||||||
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
|
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
|
||||||
resolveRoomId()
|
resolveRoomId()
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ class JoinedRoomLoadedFlowNode(
|
|||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
override val graph = roomGraphFactory.create(inputs.room)
|
override val graph = roomGraphFactory.create(inputs.room)
|
||||||
|
|
||||||
|
private val sendMessageWatcher = (graph as? TimelineBindings)?.analyticsSendMessageWatcher
|
||||||
|
|
||||||
// This is an ugly hack to check activity recreation
|
// This is an ugly hack to check activity recreation
|
||||||
private var currentActivity: Activity? = null
|
private var currentActivity: Activity? = null
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ class JoinedRoomLoadedFlowNode(
|
|||||||
Timber.v("OnCreate => ${inputs.room.roomId}")
|
Timber.v("OnCreate => ${inputs.room.roomId}")
|
||||||
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
|
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
|
||||||
activeRoomsHolder.addRoom(inputs.room)
|
activeRoomsHolder.addRoom(inputs.room)
|
||||||
|
sendMessageWatcher?.start()
|
||||||
fetchRoomMembers()
|
fetchRoomMembers()
|
||||||
trackVisitedRoom()
|
trackVisitedRoom()
|
||||||
},
|
},
|
||||||
@@ -120,6 +123,7 @@ class JoinedRoomLoadedFlowNode(
|
|||||||
},
|
},
|
||||||
onDestroy = {
|
onDestroy = {
|
||||||
Timber.v("OnDestroy")
|
Timber.v("OnDestroy")
|
||||||
|
sendMessageWatcher?.stop()
|
||||||
// If we're just going through an activity recreation there's no need to destroy the Room object
|
// If we're just going through an activity recreation there's no need to destroy the Room object
|
||||||
// Destroying it would actually cause an issue where its methods can no longer be called
|
// Destroying it would actually cause an issue where its methods can no longer be called
|
||||||
if (currentActivity?.isChangingConfigurations != true) {
|
if (currentActivity?.isChangingConfigurations != true) {
|
||||||
|
|||||||
@@ -19,22 +19,29 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
|||||||
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
|
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.appnav.di.RoomGraphFactory
|
import io.element.android.appnav.di.RoomGraphFactory
|
||||||
|
import io.element.android.appnav.di.TimelineBindings
|
||||||
import io.element.android.appnav.room.RoomNavigationTarget
|
import io.element.android.appnav.room.RoomNavigationTarget
|
||||||
import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback
|
import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback
|
||||||
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
||||||
import io.element.android.features.forward.api.ForwardEntryPoint
|
import io.element.android.features.forward.api.ForwardEntryPoint
|
||||||
import io.element.android.features.forward.test.FakeForwardEntryPoint
|
import io.element.android.features.forward.test.FakeForwardEntryPoint
|
||||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||||
|
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
|
||||||
|
import io.element.android.features.messages.test.pinned.FakePinnedEventsTimelineProvider
|
||||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||||
import io.element.android.features.space.api.SpaceEntryPoint
|
import io.element.android.features.space.api.SpaceEntryPoint
|
||||||
import io.element.android.libraries.architecture.childNode
|
import io.element.android.libraries.architecture.childNode
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
|
||||||
|
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
import io.element.android.services.analytics.test.watchers.FakeAnalyticsSendMessageWatcher
|
||||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||||
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
|
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
|
||||||
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
|
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
|
||||||
@@ -72,9 +79,20 @@ class JoinedRoomLoadedFlowNodeTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeRoomGraphFactory : RoomGraphFactory {
|
private class FakeRoomGraphFactory(
|
||||||
|
private val timelineProvider: FakeTimelineProvider = FakeTimelineProvider(),
|
||||||
|
private val pinnedEventsTimelineProvider: FakePinnedEventsTimelineProvider = FakePinnedEventsTimelineProvider(),
|
||||||
|
private val analyticsSendMessageWatcher: FakeAnalyticsSendMessageWatcher = FakeAnalyticsSendMessageWatcher(),
|
||||||
|
) : RoomGraphFactory {
|
||||||
override fun create(room: JoinedRoom): Any {
|
override fun create(room: JoinedRoom): Any {
|
||||||
return Unit
|
return object : TimelineBindings {
|
||||||
|
override val timelineProvider: TimelineProvider
|
||||||
|
get() = this@FakeRoomGraphFactory.timelineProvider
|
||||||
|
override val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
|
||||||
|
get() = this@FakeRoomGraphFactory.pinnedEventsTimelineProvider
|
||||||
|
override val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher
|
||||||
|
get() = this@FakeRoomGraphFactory.analyticsSendMessageWatcher
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
|
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
|
||||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
|
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
|
||||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
|
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
|
||||||
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.finishLongRunningTransaction
|
||||||
@@ -205,7 +205,7 @@ class TimelinePresenter(
|
|||||||
}.start()
|
}.start()
|
||||||
is TimelineEvents.OnFocusEventRender -> {
|
is TimelineEvents.OnFocusEventRender -> {
|
||||||
// If there was a pending 'notification tap opens timeline' transaction, finish it now we're focused in the required event
|
// If there was a pending 'notification tap opens timeline' transaction, finish it now we're focused in the required event
|
||||||
analyticsService.finishLongRunningTransaction(NotificationTapOpensTimeline)
|
analyticsService.finishLongRunningTransaction(NotificationToMessage)
|
||||||
|
|
||||||
focusRequestState.value = focusRequestState.value.onFocusEventRender()
|
focusRequestState.value = focusRequestState.value.onFocusEventRender()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.test.pinned
|
||||||
|
|
||||||
|
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class FakePinnedEventsTimelineProvider(
|
||||||
|
private val fakeTimelineProvider: FakeTimelineProvider = FakeTimelineProvider(),
|
||||||
|
) : PinnedEventsTimelineProvider {
|
||||||
|
override fun activeTimelineFlow(): StateFlow<Timeline?> = fakeTimelineProvider.activeTimelineFlow()
|
||||||
|
}
|
||||||
@@ -177,4 +177,9 @@ interface JoinedRoom : BaseRoom {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
suspend fun withdrawVerificationAndResend(userIds: List<UserId>, sendHandle: SendHandle): Result<Unit>
|
suspend fun withdrawVerificationAndResend(userIds: List<UserId>, sendHandle: SendHandle): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a [Flow] of [SendQueueUpdate] related to this room.
|
||||||
|
*/
|
||||||
|
fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
sealed interface SendQueueUpdate {
|
||||||
|
data class NewLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
|
||||||
|
data class CancelledLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
|
||||||
|
data class ReplacedLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
|
||||||
|
data class SendError(val transactionId: TransactionId) : SendQueueUpdate
|
||||||
|
data class RetrySendingEvent(val transactionId: TransactionId) : SendQueueUpdate
|
||||||
|
data class SentEvent(val transactionId: TransactionId, val eventId: EventId) : SendQueueUpdate
|
||||||
|
data class MediaUpload(val relatedTo: EventId, val file: MediaSource?, val index: Long, val progress: Float) : SendQueueUpdate
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
|||||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
|
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
|
||||||
|
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
|
||||||
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||||
@@ -66,6 +67,8 @@ import org.matrix.rustcomponents.sdk.DateDividerMode
|
|||||||
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
||||||
import org.matrix.rustcomponents.sdk.KnockRequestsListener
|
import org.matrix.rustcomponents.sdk.KnockRequestsListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
|
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
|
||||||
|
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
|
||||||
|
import org.matrix.rustcomponents.sdk.SendQueueListener
|
||||||
import org.matrix.rustcomponents.sdk.TimelineConfiguration
|
import org.matrix.rustcomponents.sdk.TimelineConfiguration
|
||||||
import org.matrix.rustcomponents.sdk.TimelineFilter
|
import org.matrix.rustcomponents.sdk.TimelineFilter
|
||||||
import org.matrix.rustcomponents.sdk.TimelineFocus
|
import org.matrix.rustcomponents.sdk.TimelineFocus
|
||||||
@@ -486,6 +489,16 @@ class JoinedRustRoom(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate> {
|
||||||
|
return mxCallbackFlow {
|
||||||
|
innerRoom.subscribeToSendQueueUpdates(object : SendQueueListener {
|
||||||
|
override fun onUpdate(update: RoomSendQueueUpdate) {
|
||||||
|
trySend(update.map())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() = destroy()
|
override fun close() = destroy()
|
||||||
|
|
||||||
override fun destroy() {
|
override fun destroy() {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.room
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||||
|
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
|
||||||
|
import io.element.android.libraries.matrix.impl.media.map
|
||||||
|
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
|
||||||
|
|
||||||
|
fun RoomSendQueueUpdate.map(): SendQueueUpdate = when (this) {
|
||||||
|
is RoomSendQueueUpdate.NewLocalEvent -> SendQueueUpdate.NewLocalEvent(TransactionId(transactionId))
|
||||||
|
is RoomSendQueueUpdate.CancelledLocalEvent -> SendQueueUpdate.CancelledLocalEvent(TransactionId(transactionId))
|
||||||
|
is RoomSendQueueUpdate.MediaUpload -> SendQueueUpdate.MediaUpload(
|
||||||
|
relatedTo = EventId(relatedTo),
|
||||||
|
file = file?.map(),
|
||||||
|
index = index.toLong(),
|
||||||
|
progress = progress.current.toFloat() / progress.total.toFloat(),
|
||||||
|
)
|
||||||
|
is RoomSendQueueUpdate.ReplacedLocalEvent -> SendQueueUpdate.ReplacedLocalEvent(TransactionId(transactionId))
|
||||||
|
is RoomSendQueueUpdate.RetryEvent -> SendQueueUpdate.RetrySendingEvent(TransactionId(transactionId))
|
||||||
|
is RoomSendQueueUpdate.SendError -> SendQueueUpdate.SendError(TransactionId(transactionId))
|
||||||
|
is RoomSendQueueUpdate.SentEvent -> SendQueueUpdate.SentEvent(TransactionId(transactionId), EventId(eventId))
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
|||||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
|
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
|
||||||
|
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
|
||||||
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||||
@@ -39,6 +40,7 @@ import io.element.android.tests.testutils.simulateLongTask
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
@@ -83,6 +85,8 @@ class FakeJoinedRoom(
|
|||||||
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
|
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
|
||||||
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
|
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
|
||||||
) : JoinedRoom, BaseRoom by baseRoom {
|
) : JoinedRoom, BaseRoom by baseRoom {
|
||||||
|
private val sendQueueUpdates = MutableSharedFlow<SendQueueUpdate>(extraBufferCapacity = 10)
|
||||||
|
|
||||||
fun givenRoomMembersState(state: RoomMembersState) {
|
fun givenRoomMembersState(state: RoomMembersState) {
|
||||||
baseRoom.givenRoomMembersState(state)
|
baseRoom.givenRoomMembersState(state)
|
||||||
}
|
}
|
||||||
@@ -219,6 +223,10 @@ class FakeJoinedRoom(
|
|||||||
withdrawVerificationAndResendResult(userIds, sendHandle)
|
withdrawVerificationAndResendResult(userIds, sendHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate> {
|
||||||
|
return sendQueueUpdates
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) {
|
private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) {
|
||||||
progressCallbackValues.forEach { (current, total) ->
|
progressCallbackValues.forEach { (current, total) ->
|
||||||
progressCallback?.onProgress(current, total)
|
progressCallback?.onProgress(current, total)
|
||||||
@@ -229,4 +237,8 @@ class FakeJoinedRoom(
|
|||||||
fun emitSyncUpdate() {
|
fun emitSyncUpdate() {
|
||||||
(syncUpdateFlow as MutableStateFlow).value = syncUpdateFlow.value + 1
|
(syncUpdateFlow as MutableStateFlow).value = syncUpdateFlow.value + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun givenSendQueueUpdate(sendQueueUpdate: SendQueueUpdate) {
|
||||||
|
sendQueueUpdates.emit(sendQueueUpdate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,24 @@
|
|||||||
|
|
||||||
package io.element.android.services.analytics.api
|
package io.element.android.services.analytics.api
|
||||||
|
|
||||||
|
import io.element.android.services.analyticsproviders.api.AnalyticsTransactions
|
||||||
|
import io.element.android.services.analyticsproviders.api.TransactionDefinition
|
||||||
|
|
||||||
sealed class AnalyticsLongRunningTransaction(
|
sealed class AnalyticsLongRunningTransaction(
|
||||||
val name: String,
|
val name: String,
|
||||||
val operation: String?,
|
val operation: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
) {
|
) {
|
||||||
data object ColdStartUntilCachedRoomList : AnalyticsLongRunningTransaction("Cold start until cached room list is displayed", null)
|
constructor(definition: TransactionDefinition) : this(definition.name, definition.operation, definition.description)
|
||||||
data object FirstRoomsDisplayed : AnalyticsLongRunningTransaction("First rooms displayed after login or restoration", null)
|
|
||||||
data object ResumeAppUntilNewRoomsReceived : AnalyticsLongRunningTransaction("App was resumed and new room list items arrived", null)
|
// UX flows
|
||||||
data object NotificationTapOpensTimeline : AnalyticsLongRunningTransaction("A notification was tapped and it opened a timeline", null)
|
data object ColdStart : AnalyticsLongRunningTransaction(AnalyticsTransactions.coldStart)
|
||||||
data object OpenRoom : AnalyticsLongRunningTransaction("Open a room and see loaded items in the timeline", null)
|
data object CatchUp : AnalyticsLongRunningTransaction(AnalyticsTransactions.catchUp)
|
||||||
|
data object NotificationToMessage : AnalyticsLongRunningTransaction(AnalyticsTransactions.notificationToMessage)
|
||||||
|
data object OpenRoom : AnalyticsLongRunningTransaction(AnalyticsTransactions.openRoom)
|
||||||
|
|
||||||
|
// Technical flows
|
||||||
|
data object FirstRoomsDisplayed : AnalyticsLongRunningTransaction("First rooms displayed after login or restoration", null, null)
|
||||||
data object LoadJoinedRoomFlow : AnalyticsLongRunningTransaction("Load joined room UI", "ui.load")
|
data object LoadJoinedRoomFlow : AnalyticsLongRunningTransaction("Load joined room UI", "ui.load")
|
||||||
data object LoadMessagesUi : AnalyticsLongRunningTransaction("Load messages UI", "ui.load")
|
data object LoadMessagesUi : AnalyticsLongRunningTransaction("Load messages UI", "ui.load")
|
||||||
data object DisplayFirstTimelineItems : AnalyticsLongRunningTransaction("Get and display first timeline items", null)
|
data object DisplayFirstTimelineItems : AnalyticsLongRunningTransaction("Get and display first timeline items", null)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker {
|
|||||||
/**
|
/**
|
||||||
* Starts a transaction to measure the performance of an operation.
|
* Starts a transaction to measure the performance of an operation.
|
||||||
*/
|
*/
|
||||||
fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction
|
fun startTransaction(name: String, operation: String? = null, description: String? = null): AnalyticsTransaction
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts an [AnalyticsLongRunningTransaction], that can be shared with other components.
|
* Starts an [AnalyticsLongRunningTransaction], that can be shared with other components.
|
||||||
@@ -80,11 +80,12 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker {
|
|||||||
inline fun <T> AnalyticsService.recordTransaction(
|
inline fun <T> AnalyticsService.recordTransaction(
|
||||||
name: String,
|
name: String,
|
||||||
operation: String,
|
operation: String,
|
||||||
|
description: String? = null,
|
||||||
parentTransaction: AnalyticsTransaction? = null,
|
parentTransaction: AnalyticsTransaction? = null,
|
||||||
block: (AnalyticsTransaction) -> T
|
block: (AnalyticsTransaction) -> T
|
||||||
): T {
|
): T {
|
||||||
val transaction = parentTransaction?.startChild(name, operation)
|
val transaction = parentTransaction?.startChild(operation, description)
|
||||||
?: startTransaction(name, operation)
|
?: startTransaction(name, operation, description)
|
||||||
try {
|
try {
|
||||||
val result = block(transaction)
|
val result = block(transaction)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.services.analytics.api.watchers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An analytics watcher tracking the time it took the client to send a message.
|
||||||
|
*/
|
||||||
|
interface AnalyticsSendMessageWatcher {
|
||||||
|
/**
|
||||||
|
* Start listening to send queue updates and tracking the sending states of the events.
|
||||||
|
*/
|
||||||
|
fun start()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop observing the sending states of the events.
|
||||||
|
*/
|
||||||
|
fun stop()
|
||||||
|
}
|
||||||
@@ -161,9 +161,9 @@ class DefaultAnalyticsService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction {
|
override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction {
|
||||||
return if (userConsent.get()) {
|
return if (userConsent.get()) {
|
||||||
analyticsProviders.firstNotNullOfOrNull { it.startTransaction(name, operation) }
|
analyticsProviders.firstNotNullOfOrNull { it.startTransaction(name, operation, description) }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
} ?: NoopAnalyticsTransaction
|
} ?: NoopAnalyticsTransaction
|
||||||
@@ -173,8 +173,8 @@ class DefaultAnalyticsService(
|
|||||||
longRunningTransaction: AnalyticsLongRunningTransaction,
|
longRunningTransaction: AnalyticsLongRunningTransaction,
|
||||||
parentTransaction: AnalyticsTransaction?,
|
parentTransaction: AnalyticsTransaction?,
|
||||||
): AnalyticsTransaction {
|
): AnalyticsTransaction {
|
||||||
val transaction = parentTransaction?.startChild(longRunningTransaction.name, longRunningTransaction.operation)
|
val transaction = parentTransaction?.startChild(longRunningTransaction.operation.orEmpty(), longRunningTransaction.name)
|
||||||
?: startTransaction(longRunningTransaction.name, longRunningTransaction.operation)
|
?: startTransaction(longRunningTransaction.name, longRunningTransaction.operation, longRunningTransaction.description)
|
||||||
|
|
||||||
pendingLongRunningTransactions[longRunningTransaction] = transaction
|
pendingLongRunningTransactions[longRunningTransaction] = transaction
|
||||||
return transaction
|
return transaction
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class DefaultAnalyticsColdStartWatcher(
|
|||||||
if (hasConsent) {
|
if (hasConsent) {
|
||||||
if (isColdStart.get()) {
|
if (isColdStart.get()) {
|
||||||
Timber.d("Starting cold start check")
|
Timber.d("Starting cold start check")
|
||||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList)
|
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart)
|
||||||
} else {
|
} else {
|
||||||
error("The app is no longer in a cold start state")
|
error("The app is no longer in a cold start state")
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ class DefaultAnalyticsColdStartWatcher(
|
|||||||
|
|
||||||
override fun whenLoggingIn() {
|
override fun whenLoggingIn() {
|
||||||
if (isColdStart.getAndSet(false)) {
|
if (isColdStart.getAndSet(false)) {
|
||||||
analyticsService.cancelLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList)
|
analyticsService.cancelLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart)
|
||||||
Timber.d("Canceled cold start check: user is logging in")
|
Timber.d("Canceled cold start check: user is logging in")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ class DefaultAnalyticsColdStartWatcher(
|
|||||||
override fun onRoomListVisible() {
|
override fun onRoomListVisible() {
|
||||||
if (isColdStart.getAndSet(false)) {
|
if (isColdStart.getAndSet(false)) {
|
||||||
Timber.d("Room list is visible, finishing cold start check")
|
Timber.d("Room list is visible, finishing cold start check")
|
||||||
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList)
|
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class DefaultAnalyticsRoomListStateWatcher(
|
|||||||
.withPreviousValue()
|
.withPreviousValue()
|
||||||
.onEach { (wasInForeground, isInForeground) ->
|
.onEach { (wasInForeground, isInForeground) ->
|
||||||
if (isInForeground && roomListService.state.value != RoomListService.State.Running) {
|
if (isInForeground && roomListService.state.value != RoomListService.State.Running) {
|
||||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived)
|
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.CatchUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasInForeground == false && isInForeground) {
|
if (wasInForeground == false && isInForeground) {
|
||||||
@@ -64,7 +64,7 @@ class DefaultAnalyticsRoomListStateWatcher(
|
|||||||
roomListService.state
|
roomListService.state
|
||||||
.onEach { state ->
|
.onEach { state ->
|
||||||
if (state == RoomListService.State.Running && isWarmState.get()) {
|
if (state == RoomListService.State.Running && isWarmState.get()) {
|
||||||
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived)
|
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.CatchUp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(coroutineScope)
|
.launchIn(coroutineScope)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.services.analytics.impl.watchers
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.annotations.RoomCoroutineScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
|
||||||
|
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
|
||||||
|
import io.element.android.services.analyticsproviders.api.AnalyticsTransactions
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
private const val TAG = "SendMessageWatcher"
|
||||||
|
|
||||||
|
@SingleIn(RoomScope::class)
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class DefaultAnalyticsSendMessageWatcher(
|
||||||
|
private val room: JoinedRoom,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
|
@RoomCoroutineScope private val coroutineScope: CoroutineScope,
|
||||||
|
) : AnalyticsSendMessageWatcher {
|
||||||
|
private val pendingEvents = ConcurrentHashMap<TransactionId, AnalyticsTransaction>()
|
||||||
|
private var sendQueueWatchJob: Job? = null
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
override fun start() {
|
||||||
|
Timber.tag(TAG).d("Starting SendMessageWatcher")
|
||||||
|
sendQueueWatchJob?.cancel()
|
||||||
|
sendQueueWatchJob = room.subscribeToSendQueueUpdates()
|
||||||
|
.onEach { update ->
|
||||||
|
// We received a new local event
|
||||||
|
when (update) {
|
||||||
|
is SendQueueUpdate.NewLocalEvent -> {
|
||||||
|
Timber.tag(TAG).d("Event with transaction id ${update.transactionId} sent")
|
||||||
|
watch(update.transactionId)
|
||||||
|
}
|
||||||
|
is SendQueueUpdate.SentEvent -> {
|
||||||
|
val pendingTransaction = pendingEvents.remove(update.transactionId)
|
||||||
|
if (pendingTransaction != null) {
|
||||||
|
Timber.tag(TAG).d("Sent event with transaction id ${update.transactionId} received in sync")
|
||||||
|
pendingTransaction.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
Timber.tag(TAG).d("Stopping SendMessageWatcher")
|
||||||
|
sendQueueWatchJob?.cancel()
|
||||||
|
sendQueueWatchJob = null
|
||||||
|
pendingEvents.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun watch(transactionId: TransactionId) {
|
||||||
|
pendingEvents[transactionId] = with(AnalyticsTransactions.sendMessage) {
|
||||||
|
analyticsService.startTransaction(
|
||||||
|
name = name,
|
||||||
|
operation = operation,
|
||||||
|
description = description,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
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.services.analytics.api.AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList
|
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ColdStart
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
@@ -31,14 +31,14 @@ class DefaultAnalyticsColdStartWatcherTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction is running
|
// The transaction is running
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNotNull()
|
assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNotNull()
|
||||||
|
|
||||||
// As soon as the room list is visible
|
// As soon as the room list is visible
|
||||||
watcher.onRoomListVisible()
|
watcher.onRoomListVisible()
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction is now finished
|
// The transaction is now finished
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull()
|
assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -54,14 +54,14 @@ class DefaultAnalyticsColdStartWatcherTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction is running
|
// The transaction is running
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNotNull()
|
assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNotNull()
|
||||||
|
|
||||||
// If the user starts a login flow
|
// If the user starts a login flow
|
||||||
watcher.whenLoggingIn()
|
watcher.whenLoggingIn()
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction is gone
|
// The transaction is gone
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull()
|
assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -80,7 +80,7 @@ class DefaultAnalyticsColdStartWatcherTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction never starts
|
// The transaction never starts
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull()
|
assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -95,7 +95,7 @@ class DefaultAnalyticsColdStartWatcherTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction is not running in that case
|
// The transaction is not running in that case
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull()
|
assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TestScope.createAnalyticsColdStartWatcher(
|
private fun TestScope.createAnalyticsColdStartWatcher(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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.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.ResumeAppUntilNewRoomsReceived
|
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.api.AppNavigationState
|
||||||
import io.element.android.services.appnavstate.api.NavigationState
|
import io.element.android.services.appnavstate.api.NavigationState
|
||||||
@@ -49,14 +49,14 @@ class DefaultAnalyticsRoomListStateWatcherTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction should be present now
|
// The transaction should be present now
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull()
|
assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull()
|
||||||
|
|
||||||
// And now the room list service running
|
// And now the room list service running
|
||||||
roomListService.postState(RoomListService.State.Running)
|
roomListService.postState(RoomListService.State.Running)
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// And the transaction should now be gone
|
// And the transaction should now be gone
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull()
|
assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull()
|
||||||
|
|
||||||
watcher.stop()
|
watcher.stop()
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ class DefaultAnalyticsRoomListStateWatcherTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction was never present
|
// The transaction was never present
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull()
|
assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull()
|
||||||
|
|
||||||
watcher.stop()
|
watcher.stop()
|
||||||
}
|
}
|
||||||
@@ -116,12 +116,12 @@ class DefaultAnalyticsRoomListStateWatcherTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction should be present now
|
// The transaction should be present now
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull()
|
assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull()
|
||||||
|
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// But without the room list syncing, it never finishes
|
// But without the room list syncing, it never finishes
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull()
|
assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull()
|
||||||
|
|
||||||
watcher.stop()
|
watcher.stop()
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ class DefaultAnalyticsRoomListStateWatcherTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
// The transaction was never added
|
// The transaction was never added
|
||||||
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull()
|
assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull()
|
||||||
|
|
||||||
watcher.stop()
|
watcher.stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.services.analytics.impl.watchers
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.services.analytics.api.NoopAnalyticsTransaction
|
||||||
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DefaultAnalyticsSendMessageWatcherTest {
|
||||||
|
@Test
|
||||||
|
fun `test start listens to send queue updates`() = runTest {
|
||||||
|
val mockedTransaction = mockk<NoopAnalyticsTransaction>(relaxed = true)
|
||||||
|
val startTransactionRecorder = lambdaRecorder { _: String, _: String?, _: String? -> mockedTransaction }
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
val analyticsService = FakeAnalyticsService(startTransactionLambda = startTransactionRecorder)
|
||||||
|
|
||||||
|
val watcher = createDefaultAnalyticsSendMessageWatcher(room = room, analyticsService = analyticsService)
|
||||||
|
|
||||||
|
// When we start listening, we don't trigger any analyticsService.startTransaction calls
|
||||||
|
watcher.start()
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
startTransactionRecorder.assertions().isNeverCalled()
|
||||||
|
|
||||||
|
// When we receive a new local event, we start a new transaction for it
|
||||||
|
room.givenSendQueueUpdate(SendQueueUpdate.NewLocalEvent(A_TRANSACTION_ID))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
startTransactionRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
// And we receive an 'event sent' update with the event's id, we finish the transaction
|
||||||
|
room.givenSendQueueUpdate(SendQueueUpdate.SentEvent(A_TRANSACTION_ID, AN_EVENT_ID))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
verify { mockedTransaction.finish() }
|
||||||
|
|
||||||
|
// We also stop the watcher for cleanup
|
||||||
|
watcher.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createDefaultAnalyticsSendMessageWatcher(
|
||||||
|
room: FakeJoinedRoom = FakeJoinedRoom(),
|
||||||
|
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||||
|
) = DefaultAnalyticsSendMessageWatcher(
|
||||||
|
room = room,
|
||||||
|
analyticsService = analyticsService,
|
||||||
|
coroutineScope = backgroundScope,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ class NoopAnalyticsService : AnalyticsService {
|
|||||||
override fun updateUserProperties(userProperties: UserProperties) = Unit
|
override fun updateUserProperties(userProperties: UserProperties) = Unit
|
||||||
override fun trackError(throwable: Throwable) = Unit
|
override fun trackError(throwable: Throwable) = Unit
|
||||||
override fun updateSuperProperties(updatedProperties: SuperProperties) = Unit
|
override fun updateSuperProperties(updatedProperties: SuperProperties) = Unit
|
||||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction
|
override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction
|
||||||
override fun startLongRunningTransaction(
|
override fun startLongRunningTransaction(
|
||||||
longRunningTransaction: AnalyticsLongRunningTransaction,
|
longRunningTransaction: AnalyticsLongRunningTransaction,
|
||||||
parentTransaction: AnalyticsTransaction?,
|
parentTransaction: AnalyticsTransaction?,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.services.analytics.noop.watchers
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
|
||||||
|
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class NoopAnalyticsSendMessageWatcher : AnalyticsSendMessageWatcher {
|
||||||
|
override fun start() = Unit
|
||||||
|
override fun stop() = Unit
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
class FakeAnalyticsService(
|
class FakeAnalyticsService(
|
||||||
isEnabled: Boolean = false,
|
isEnabled: Boolean = false,
|
||||||
didAskUserConsent: Boolean = false,
|
didAskUserConsent: Boolean = false,
|
||||||
|
private val startTransactionLambda: (String, String?, String?) -> AnalyticsTransaction = { _, _, _ -> NoopAnalyticsTransaction },
|
||||||
) : AnalyticsService {
|
) : AnalyticsService {
|
||||||
private val isEnabledFlow = MutableStateFlow(isEnabled)
|
private val isEnabledFlow = MutableStateFlow(isEnabled)
|
||||||
override val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
|
override val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
|
||||||
@@ -72,7 +73,11 @@ class FakeAnalyticsService(
|
|||||||
// No op
|
// No op
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction
|
override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction = startTransactionLambda(
|
||||||
|
name,
|
||||||
|
operation,
|
||||||
|
description
|
||||||
|
)
|
||||||
override fun startLongRunningTransaction(
|
override fun startLongRunningTransaction(
|
||||||
longRunningTransaction: AnalyticsLongRunningTransaction,
|
longRunningTransaction: AnalyticsLongRunningTransaction,
|
||||||
parentTransaction: AnalyticsTransaction?
|
parentTransaction: AnalyticsTransaction?
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.services.analytics.test.watchers
|
||||||
|
|
||||||
|
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
|
||||||
|
|
||||||
|
class FakeAnalyticsSendMessageWatcher(
|
||||||
|
private val startLambda: () -> Unit = {},
|
||||||
|
private val stopLambda: () -> Unit = {},
|
||||||
|
) : AnalyticsSendMessageWatcher {
|
||||||
|
override fun start() = startLambda()
|
||||||
|
override fun stop() = stopLambda()
|
||||||
|
}
|
||||||
@@ -21,5 +21,5 @@ interface AnalyticsProvider : AnalyticsTracker, ErrorTracker {
|
|||||||
|
|
||||||
fun stop()
|
fun stop()
|
||||||
|
|
||||||
fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction?
|
fun startTransaction(name: String, operation: String? = null, description: String? = null): AnalyticsTransaction?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.services.analyticsproviders.api
|
||||||
|
|
||||||
|
object AnalyticsTransactions {
|
||||||
|
val coldStart = TransactionDefinition(
|
||||||
|
name = "Cold start",
|
||||||
|
operation = "ux",
|
||||||
|
description = "Cold start until the cached room list is displayed",
|
||||||
|
)
|
||||||
|
|
||||||
|
val catchUp = TransactionDefinition(
|
||||||
|
name = "Catch-up",
|
||||||
|
operation = "ux",
|
||||||
|
description = "The app syncs and the room list becomes up-to-date",
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationToMessage = TransactionDefinition(
|
||||||
|
name = "Notification to message",
|
||||||
|
operation = "ux",
|
||||||
|
description = "A notification was tapped and it opened a timeline",
|
||||||
|
)
|
||||||
|
|
||||||
|
val openRoom = TransactionDefinition(
|
||||||
|
name = "Open a room",
|
||||||
|
operation = "ux",
|
||||||
|
description = "Open a room and see loaded items in the timeline",
|
||||||
|
)
|
||||||
|
|
||||||
|
val sendMessage = TransactionDefinition(
|
||||||
|
name = "Send a message",
|
||||||
|
operation = "ux",
|
||||||
|
description = "Send to sent state in timeline",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TransactionDefinition(
|
||||||
|
val name: String,
|
||||||
|
val operation: String? = null,
|
||||||
|
val description: String?,
|
||||||
|
)
|
||||||
@@ -124,7 +124,7 @@ class PosthogAnalyticsProvider(
|
|||||||
return withSuperProperties.takeIf { it.isEmpty().not() }
|
return withSuperProperties.takeIf { it.isEmpty().not() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null
|
override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Map<String, Any?>.keepOnlyNonNullValues(): Map<String, Any> {
|
private fun Map<String, Any?>.keepOnlyNonNullValues(): Map<String, Any> {
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ class SentryAnalyticsProvider(
|
|||||||
Sentry.captureException(throwable)
|
Sentry.captureException(throwable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? {
|
override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction? {
|
||||||
return SentryAnalyticsTransaction(name, operation)
|
return SentryAnalyticsTransaction(name, operation, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import io.sentry.Sentry
|
|||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction {
|
class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction {
|
||||||
constructor(name: String, operation: String?) : this(Sentry.startTransaction(name, operation.orEmpty()))
|
constructor(name: String, operation: String?, description: String? = null) : this(
|
||||||
|
Sentry.startTransaction(name, operation.orEmpty()).also { it.description = description }
|
||||||
|
)
|
||||||
private val inner = span
|
private val inner = span
|
||||||
|
|
||||||
override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction(
|
override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction(
|
||||||
@@ -30,7 +32,7 @@ class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTra
|
|||||||
}
|
}
|
||||||
override fun finish() {
|
override fun finish() {
|
||||||
val name = if (inner is ITransaction) inner.name else inner.operation
|
val name = if (inner is ITransaction) inner.name else inner.operation
|
||||||
Timber.d("Finishing transaction: $name")
|
Timber.d("Finishing transaction: '$name'")
|
||||||
inner.finish()
|
inner.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,5 @@ class FakeAnalyticsProvider(
|
|||||||
override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties)
|
override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties)
|
||||||
override fun trackError(throwable: Throwable) = trackErrorLambda(throwable)
|
override fun trackError(throwable: Throwable) = trackErrorLambda(throwable)
|
||||||
override fun updateSuperProperties(updatedProperties: SuperProperties) = updateSuperPropertiesLambda(updatedProperties)
|
override fun updateSuperProperties(updatedProperties: SuperProperties) = updateSuperPropertiesLambda(updatedProperties)
|
||||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null
|
override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction? = null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user