diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 84d614b3d9..8bc821f933 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.features.forward.test) + testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.rageshake.test) testImplementation(projects.services.appnavstate.impl) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 4687946367..1ec1678d71 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -320,7 +320,7 @@ class RootFlowNode( is ResolvedIntent.Navigation -> { val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false) if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) { - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline) + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationToMessage) } navigateTo(resolvedIntent.deeplinkData) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt index cb78760a0f..371b067637 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt @@ -10,8 +10,10 @@ package io.element.android.appnav.di import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher interface TimelineBindings { val timelineProvider: TimelineProvider val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider + val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index 1bf3516900..6446f754b0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -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.ui.room.LoadingRoomState 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.AnalyticsService import kotlinx.coroutines.flow.SharingStarted @@ -128,7 +128,7 @@ class RoomFlowNode( override fun onBuilt() { super.onBuilt() - val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline) + val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage) val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction) analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction) resolveRoomId() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index 0b3ba57776..05ebab2b10 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -98,6 +98,8 @@ class JoinedRoomLoadedFlowNode( private val callback: Callback = callback() override val graph = roomGraphFactory.create(inputs.room) + private val sendMessageWatcher = (graph as? TimelineBindings)?.analyticsSendMessageWatcher + // This is an ugly hack to check activity recreation private var currentActivity: Activity? = null @@ -109,6 +111,7 @@ class JoinedRoomLoadedFlowNode( Timber.v("OnCreate => ${inputs.room.roomId}") appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) activeRoomsHolder.addRoom(inputs.room) + sendMessageWatcher?.start() fetchRoomMembers() trackVisitedRoom() }, @@ -120,6 +123,7 @@ class JoinedRoomLoadedFlowNode( }, onDestroy = { Timber.v("OnDestroy") + sendMessageWatcher?.stop() // 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 if (currentActivity?.isChangingConfigurations != true) { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt index 6cd7df025b..8d514a2c0f 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt @@ -19,22 +19,29 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.google.common.truth.Truth.assertThat 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.joined.FakeJoinedRoomLoadedFlowNodeCallback import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.test.FakeForwardEntryPoint 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.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.childNode 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.FakeMatrixClient 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.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.watchers.FakeAnalyticsSendMessageWatcher import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder 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 { - 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 + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 5bafaf3e88..fd7d200a52 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -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.preferences.api.store.SessionPreferencesStore 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.AnalyticsService import io.element.android.services.analytics.api.finishLongRunningTransaction @@ -205,7 +205,7 @@ class TimelinePresenter( }.start() is TimelineEvents.OnFocusEventRender -> { // 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() } diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt new file mode 100644 index 0000000000..cb90db29af --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt @@ -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 = fakeTimelineProvider.activeTimelineFlow() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt index 1cfcfc46f2..b804bdf46d 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt @@ -177,4 +177,9 @@ interface JoinedRoom : BaseRoom { * */ suspend fun withdrawVerificationAndResend(userIds: List, sendHandle: SendHandle): Result + + /** + * Subscribe to a [Flow] of [SendQueueUpdate] related to this room. + */ + fun subscribeToSendQueueUpdates(): Flow } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/SendQueueUpdate.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/SendQueueUpdate.kt new file mode 100644 index 0000000000..41e1e5643b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/SendQueueUpdate.kt @@ -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 +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 770efb3918..00d4175ee3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -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.JoinedRoom 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.join.JoinRule 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.KnockRequestsListener 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.TimelineFilter import org.matrix.rustcomponents.sdk.TimelineFocus @@ -486,6 +489,16 @@ class JoinedRustRoom( } } + override fun subscribeToSendQueueUpdates(): Flow { + return mxCallbackFlow { + innerRoom.subscribeToSendQueueUpdates(object : SendQueueListener { + override fun onUpdate(update: RoomSendQueueUpdate) { + trySend(update.map()) + } + }) + } + } + override fun close() = destroy() override fun destroy() { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/SendQueueUpdatesExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/SendQueueUpdatesExt.kt new file mode 100644 index 0000000000..efc723e1df --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/SendQueueUpdatesExt.kt @@ -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)) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt index 2d9694c909..9c97e7787b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt @@ -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.RoomMembersState 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.join.JoinRule 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.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.TestScope @@ -83,6 +85,8 @@ class FakeJoinedRoom( private val updateJoinRuleResult: (JoinRule) -> Result = { lambdaError() }, private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> }, ) : JoinedRoom, BaseRoom by baseRoom { + private val sendQueueUpdates = MutableSharedFlow(extraBufferCapacity = 10) + fun givenRoomMembersState(state: RoomMembersState) { baseRoom.givenRoomMembersState(state) } @@ -219,6 +223,10 @@ class FakeJoinedRoom( withdrawVerificationAndResendResult(userIds, sendHandle) } + override fun subscribeToSendQueueUpdates(): Flow { + return sendQueueUpdates + } + private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) { progressCallbackValues.forEach { (current, total) -> progressCallback?.onProgress(current, total) @@ -229,4 +237,8 @@ class FakeJoinedRoom( fun emitSyncUpdate() { (syncUpdateFlow as MutableStateFlow).value = syncUpdateFlow.value + 1 } + + suspend fun givenSendQueueUpdate(sendQueueUpdate: SendQueueUpdate) { + sendQueueUpdates.emit(sendQueueUpdate) + } } diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt index b318807a45..9c991cfce2 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt @@ -7,15 +7,24 @@ 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( 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) - 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) - data object NotificationTapOpensTimeline : AnalyticsLongRunningTransaction("A notification was tapped and it opened a timeline", null) - data object OpenRoom : AnalyticsLongRunningTransaction("Open a room and see loaded items in the timeline", null) + constructor(definition: TransactionDefinition) : this(definition.name, definition.operation, definition.description) + + // UX flows + data object ColdStart : AnalyticsLongRunningTransaction(AnalyticsTransactions.coldStart) + 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 LoadMessagesUi : AnalyticsLongRunningTransaction("Load messages UI", "ui.load") data object DisplayFirstTimelineItems : AnalyticsLongRunningTransaction("Get and display first timeline items", null) diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt index 365300d679..1449977d91 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt @@ -53,7 +53,7 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker { /** * 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. @@ -80,11 +80,12 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker { inline fun AnalyticsService.recordTransaction( name: String, operation: String, + description: String? = null, parentTransaction: AnalyticsTransaction? = null, block: (AnalyticsTransaction) -> T ): T { - val transaction = parentTransaction?.startChild(name, operation) - ?: startTransaction(name, operation) + val transaction = parentTransaction?.startChild(operation, description) + ?: startTransaction(name, operation, description) try { val result = block(transaction) return result diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/watchers/AnalyticsSendMessageWatcher.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/watchers/AnalyticsSendMessageWatcher.kt new file mode 100644 index 0000000000..4c3010f22e --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/watchers/AnalyticsSendMessageWatcher.kt @@ -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() +} diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt index 5db1eb1b31..64fdb1b3cd 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt @@ -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()) { - analyticsProviders.firstNotNullOfOrNull { it.startTransaction(name, operation) } + analyticsProviders.firstNotNullOfOrNull { it.startTransaction(name, operation, description) } } else { null } ?: NoopAnalyticsTransaction @@ -173,8 +173,8 @@ class DefaultAnalyticsService( longRunningTransaction: AnalyticsLongRunningTransaction, parentTransaction: AnalyticsTransaction?, ): AnalyticsTransaction { - val transaction = parentTransaction?.startChild(longRunningTransaction.name, longRunningTransaction.operation) - ?: startTransaction(longRunningTransaction.name, longRunningTransaction.operation) + val transaction = parentTransaction?.startChild(longRunningTransaction.operation.orEmpty(), longRunningTransaction.name) + ?: startTransaction(longRunningTransaction.name, longRunningTransaction.operation, longRunningTransaction.description) pendingLongRunningTransactions[longRunningTransaction] = transaction return transaction diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcher.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcher.kt index c53e28918b..e596bc35a5 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcher.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcher.kt @@ -37,7 +37,7 @@ class DefaultAnalyticsColdStartWatcher( if (hasConsent) { if (isColdStart.get()) { Timber.d("Starting cold start check") - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList) + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart) } else { error("The app is no longer in a cold start state") } @@ -49,7 +49,7 @@ class DefaultAnalyticsColdStartWatcher( override fun whenLoggingIn() { if (isColdStart.getAndSet(false)) { - analyticsService.cancelLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList) + analyticsService.cancelLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart) Timber.d("Canceled cold start check: user is logging in") } } @@ -57,7 +57,7 @@ class DefaultAnalyticsColdStartWatcher( override fun onRoomListVisible() { if (isColdStart.getAndSet(false)) { Timber.d("Room list is visible, finishing cold start check") - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList) + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart) } } } diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt index 97e438fcee..7e11e703ec 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt @@ -52,7 +52,7 @@ class DefaultAnalyticsRoomListStateWatcher( .withPreviousValue() .onEach { (wasInForeground, isInForeground) -> if (isInForeground && roomListService.state.value != RoomListService.State.Running) { - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived) + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.CatchUp) } if (wasInForeground == false && isInForeground) { @@ -64,7 +64,7 @@ class DefaultAnalyticsRoomListStateWatcher( roomListService.state .onEach { state -> if (state == RoomListService.State.Running && isWarmState.get()) { - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived) + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.CatchUp) } } .launchIn(coroutineScope) diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcher.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcher.kt new file mode 100644 index 0000000000..642107cdec --- /dev/null +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcher.kt @@ -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() + 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, + ) + } + } +} diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcherTest.kt index 325316b45c..88d6be010f 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcherTest.kt @@ -8,7 +8,7 @@ package io.element.android.services.analytics.impl.watchers 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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -31,14 +31,14 @@ class DefaultAnalyticsColdStartWatcherTest { runCurrent() // The transaction is running - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNotNull() // As soon as the room list is visible watcher.onRoomListVisible() runCurrent() // The transaction is now finished - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull() } @Test @@ -54,14 +54,14 @@ class DefaultAnalyticsColdStartWatcherTest { runCurrent() // The transaction is running - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNotNull() // If the user starts a login flow watcher.whenLoggingIn() runCurrent() // The transaction is gone - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull() } @Test @@ -80,7 +80,7 @@ class DefaultAnalyticsColdStartWatcherTest { runCurrent() // The transaction never starts - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull() } @Test @@ -95,7 +95,7 @@ class DefaultAnalyticsColdStartWatcherTest { runCurrent() // The transaction is not running in that case - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull() } private fun TestScope.createAnalyticsColdStartWatcher( diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt index 3b1f562c67..8796d6a2ee 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt @@ -10,7 +10,7 @@ package io.element.android.services.analytics.impl.watchers import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.roomlist.RoomListService 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.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.NavigationState @@ -49,14 +49,14 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // The transaction should be present now - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull() // And now the room list service running roomListService.postState(RoomListService.State.Running) runCurrent() // And the transaction should now be gone - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull() watcher.stop() } @@ -86,7 +86,7 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // The transaction was never present - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull() watcher.stop() } @@ -116,12 +116,12 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // The transaction should be present now - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull() runCurrent() // But without the room list syncing, it never finishes - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull() watcher.stop() } @@ -151,7 +151,7 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // The transaction was never added - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull() watcher.stop() } diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcherTest.kt new file mode 100644 index 0000000000..b0e996c4c0 --- /dev/null +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcherTest.kt @@ -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(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, + ) +} diff --git a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt index 004a472de3..b0c6d29b63 100644 --- a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt +++ b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt @@ -40,7 +40,7 @@ class NoopAnalyticsService : AnalyticsService { override fun updateUserProperties(userProperties: UserProperties) = Unit override fun trackError(throwable: Throwable) = 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( longRunningTransaction: AnalyticsLongRunningTransaction, parentTransaction: AnalyticsTransaction?, diff --git a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/watchers/NoopAnalyticsSendMessageWatcher.kt b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/watchers/NoopAnalyticsSendMessageWatcher.kt new file mode 100644 index 0000000000..6e9bf8058f --- /dev/null +++ b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/watchers/NoopAnalyticsSendMessageWatcher.kt @@ -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 +} diff --git a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt index b9b33744e8..f0dd9e0d4a 100644 --- a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt +++ b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow class FakeAnalyticsService( isEnabled: Boolean = false, didAskUserConsent: Boolean = false, + private val startTransactionLambda: (String, String?, String?) -> AnalyticsTransaction = { _, _, _ -> NoopAnalyticsTransaction }, ) : AnalyticsService { private val isEnabledFlow = MutableStateFlow(isEnabled) override val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) @@ -72,7 +73,11 @@ class FakeAnalyticsService( // 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( longRunningTransaction: AnalyticsLongRunningTransaction, parentTransaction: AnalyticsTransaction? diff --git a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/watchers/FakeAnalyticsSendMessageWatcher.kt b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/watchers/FakeAnalyticsSendMessageWatcher.kt new file mode 100644 index 0000000000..6320e8522e --- /dev/null +++ b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/watchers/FakeAnalyticsSendMessageWatcher.kt @@ -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() +} diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt index f90d924c81..593d62f40f 100644 --- a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt @@ -21,5 +21,5 @@ interface AnalyticsProvider : AnalyticsTracker, ErrorTracker { fun stop() - fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction? + fun startTransaction(name: String, operation: String? = null, description: String? = null): AnalyticsTransaction? } diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransactions.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransactions.kt new file mode 100644 index 0000000000..5e188263fa --- /dev/null +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransactions.kt @@ -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?, +) diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt index 9185b906c4..49edc9b1a0 100644 --- a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt @@ -124,7 +124,7 @@ class PosthogAnalyticsProvider( 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.keepOnlyNonNullValues(): Map { diff --git a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProvider.kt b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProvider.kt index 7545563736..f3e3f0c9e1 100644 --- a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProvider.kt +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProvider.kt @@ -112,8 +112,8 @@ class SentryAnalyticsProvider( Sentry.captureException(throwable) } - override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? { - return SentryAnalyticsTransaction(name, operation) + override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction? { + return SentryAnalyticsTransaction(name, operation, description) } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) diff --git a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt index 84314ee35f..6477b2cc5c 100644 --- a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt @@ -14,7 +14,9 @@ import io.sentry.Sentry import timber.log.Timber 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 override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction( @@ -30,7 +32,7 @@ class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTra } override fun finish() { val name = if (inner is ITransaction) inner.name else inner.operation - Timber.d("Finishing transaction: $name") + Timber.d("Finishing transaction: '$name'") inner.finish() } } diff --git a/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt b/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt index 9b0374bd9b..06a8f8e7aa 100644 --- a/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt +++ b/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt @@ -33,5 +33,5 @@ class FakeAnalyticsProvider( override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties) override fun trackError(throwable: Throwable) = trackErrorLambda(throwable) 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 }