From 93feed38bff374ba268a3dcf0e66581c16bfab34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 12:23:03 +0100 Subject: [PATCH] Add transaction trees for opening a room so we can have a nice trace view --- .../analytics/AnalyticsColdStartWatcher.kt | 6 ++++-- .../android/appnav/room/RoomFlowNode.kt | 8 +++++-- .../room/joined/JoinedRoomLoadedFlowNode.kt | 8 +++++++ .../appnav/JoinedRoomLoadedFlowNodeTest.kt | 2 ++ .../features/messages/impl/MessagesNode.kt | 4 ++++ .../impl/timeline/TimelinePresenter.kt | 21 ++++++++++++++++--- .../matrix/impl/room/RustRoomFactory.kt | 8 ++++++- .../api/AnalyticsLongRunningTransaction.kt | 3 +++ .../analytics/api/AnalyticsService.kt | 15 ++++++++++--- .../analytics/impl/DefaultAnalyticsService.kt | 9 ++++++-- .../analytics/noop/NoopAnalyticsService.kt | 5 ++++- .../analytics/test/FakeAnalyticsService.kt | 5 ++++- .../sentry/SentryAnalyticsTransaction.kt | 8 ++++++- 13 files changed, 86 insertions(+), 16 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsColdStartWatcher.kt b/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsColdStartWatcher.kt index 29dcd61211..0782973cc4 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsColdStartWatcher.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsColdStartWatcher.kt @@ -12,6 +12,7 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch @@ -44,7 +45,7 @@ class DefaultAnalyticsColdStartWatcher( if (hasConsent) { if (isColdStart.get()) { Timber.d("Starting cold start check") - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList) + analyticsService.startLongRunningTransaction(ColdStartUntilCachedRoomList) } else { error("The app is no longer in a cold start state") } @@ -56,6 +57,7 @@ class DefaultAnalyticsColdStartWatcher( override fun whenLoggingIn() { if (isColdStart.getAndSet(false)) { + analyticsService.removeLongRunningTransaction(ColdStartUntilCachedRoomList) Timber.d("Canceled cold start check: user is logging in") } } @@ -63,7 +65,7 @@ class DefaultAnalyticsColdStartWatcher( override fun onRoomListVisible() { if (isColdStart.getAndSet(false)) { Timber.d("Room list is visible, finishing cold start check") - analyticsService.removeLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList)?.finish() + analyticsService.removeLongRunningTransaction(ColdStartUntilCachedRoomList)?.finish() } } } 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 16bae8c30f..9aa62d6b16 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 @@ -48,7 +48,9 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership 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 +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.OpenRoom import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged @@ -111,7 +113,9 @@ class RoomFlowNode( override fun onBuilt() { super.onBuilt() - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.OpenRoom) + val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline) + 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 dab636031f..3ff8e16b49 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 @@ -45,6 +45,10 @@ import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom +import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope @@ -66,6 +70,7 @@ class JoinedRoomLoadedFlowNode( private val sessionCoroutineScope: CoroutineScope, private val matrixClient: MatrixClient, private val activeRoomsHolder: ActiveRoomsHolder, + private val analyticsService: AnalyticsService, roomGraphFactory: RoomGraphFactory, ) : BaseFlowNode( backstack = BackStack( @@ -93,6 +98,8 @@ class JoinedRoomLoadedFlowNode( init { lifecycle.subscribe( onCreate = { + val parent = analyticsService.getLongRunningTransaction(OpenRoom) + analyticsService.startLongRunningTransaction(LoadMessagesUi, parent) Timber.v("OnCreate => ${inputs.room.roomId}") appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) activeRoomsHolder.addRoom(inputs.room) @@ -100,6 +107,7 @@ class JoinedRoomLoadedFlowNode( trackVisitedRoom() }, onResume = { + analyticsService.removeLongRunningTransaction(LoadJoinedRoomFlow)?.finish() sessionCoroutineScope.launch { inputs.room.subscribeToSync() } 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 77fcbae14e..6cd7df025b 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt @@ -34,6 +34,7 @@ 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.services.analytics.test.FakeAnalyticsService import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder import io.element.android.services.appnavstate.test.FakeAppNavigationStateService @@ -123,6 +124,7 @@ class JoinedRoomLoadedFlowNodeTest { roomGraphFactory = FakeRoomGraphFactory(), matrixClient = matrixClient, activeRoomsHolder = activeRoomsHolder, + analyticsService = FakeAnalyticsService(), ) @Test diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 168a4d6b3d..2032d70d64 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -68,6 +68,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -136,6 +137,9 @@ class MessagesNode( onCreate = { sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) } }, + onResume = { + analyticsService.removeLongRunningTransaction(LoadMessagesUi)?.finish() + }, onDestroy = { mediaPlayer.close() } 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 15ceb70682..3044822256 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 @@ -55,7 +55,9 @@ 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.ui.room.canSendMessageAsState import io.element.android.libraries.preferences.api.store.SessionPreferencesStore -import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +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.OpenRoom import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -111,6 +113,11 @@ class TimelinePresenter( @Composable override fun present(): TimelineState { + LaunchedEffect(Unit) { + val parent = analyticsService.getLongRunningTransaction(OpenRoom) + analyticsService.startLongRunningTransaction(DisplayFirstTimelineItems, parent) + } + val localScope = rememberCoroutineScope() val timelineMode = remember { timelineController.mainTimelineMode() } @@ -228,18 +235,26 @@ class TimelinePresenter( LaunchedEffect(Unit) { timelineItemsFactory.timelineItems .onEach { newTimelineItems -> - analyticsService.removeLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline)?.finish() - analyticsService.removeLongRunningTransaction(AnalyticsLongRunningTransaction.OpenRoom)?.finish() timelineItemIndexer.process(newTimelineItems) timelineItems = newTimelineItems + + analyticsService.run { + removeLongRunningTransaction(DisplayFirstTimelineItems)?.finish() + removeLongRunningTransaction(OpenRoom)?.finish() + removeLongRunningTransaction(NotificationTapOpensTimeline)?.finish() + } } .launchIn(this) combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState -> + val parent = analyticsService.getLongRunningTransaction(DisplayFirstTimelineItems) + val transaction = parent?.startChild("timelineItemsFactory.replaceWith", "Processing timeline items") + transaction?.setData("items", items.count()) timelineItemsFactory.replaceWith( timelineItems = items, roomMembers = membersState.roomMembers().orEmpty() ) + transaction?.finish() items } .onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt index 083777a7b7..5ff8e5d999 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.awaitLoaded import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper import io.element.android.libraries.matrix.impl.roomlist.roomOrNull +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.recordTransaction import io.element.android.services.analyticsproviders.api.recordChildTransaction @@ -113,10 +114,13 @@ class RustRoomFactory( val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withLock null + val parentTransaction = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.OpenRoom) + if (sdkRoom.membership() == Membership.JOINED) { analyticsService.recordTransaction( name = "Get joined room", operation = "RustRoomFactory.getJoinedRoomOrPreview", + parentTransaction = parentTransaction, ) { transaction -> val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) // Init the live timeline in the SDK from the Room @@ -136,9 +140,10 @@ class RustRoomFactory( ) } + val baseRoom = transaction.recordChildTransaction(operation = "getBaseRoom", description = "Get room from SDK") { getBaseRoom(sdkRoom) } GetRoomResult.Joined( JoinedRustRoom( - baseRoom = getBaseRoom(sdkRoom), + baseRoom = baseRoom, notificationSettingsService = notificationSettingsService, roomContentForwarder = roomContentForwarder, liveInnerTimeline = timeline, @@ -152,6 +157,7 @@ class RustRoomFactory( analyticsService.recordTransaction( name = "Get preview of room", operation = "RustRoomFactory.getJoinedRoomOrPreview", + parentTransaction = parentTransaction, ) { val preview = try { sdkRoom.previewRoom(via = serverNames) 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 b173b2cb8f..b318807a45 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 @@ -16,4 +16,7 @@ sealed class AnalyticsLongRunningTransaction( 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) + 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 726ec4180d..1e95263509 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 @@ -58,7 +58,10 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker { /** * Starts an [AnalyticsLongRunningTransaction], that can be shared with other components. */ - fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction + fun startLongRunningTransaction( + longRunningTransaction: AnalyticsLongRunningTransaction, + parentTransaction: AnalyticsTransaction? = null + ): AnalyticsTransaction /** * Gets an ongoing [AnalyticsLongRunningTransaction], if it exists. @@ -71,8 +74,14 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker { fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? } -inline fun AnalyticsService.recordTransaction(name: String, operation: String, block: (AnalyticsTransaction) -> T): T { - val transaction = startTransaction(name, operation) +inline fun AnalyticsService.recordTransaction( + name: String, + operation: String, + parentTransaction: AnalyticsTransaction? = null, + block: (AnalyticsTransaction) -> T +): T { + val transaction = parentTransaction?.startChild(name, operation) + ?: startTransaction(name, operation) try { val result = block(transaction) return result 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 83c76dc2fe..f1a5b444de 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 @@ -153,8 +153,13 @@ class DefaultAnalyticsService( } ?: NoopAnalyticsTransaction } - override fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction { - val transaction = startTransaction(longRunningTransaction.name, longRunningTransaction.operation) + override fun startLongRunningTransaction( + longRunningTransaction: AnalyticsLongRunningTransaction, + parentTransaction: AnalyticsTransaction?, + ): AnalyticsTransaction { + val transaction = parentTransaction?.startChild(longRunningTransaction.name, longRunningTransaction.operation) + ?: startTransaction(longRunningTransaction.name, longRunningTransaction.operation) + pendingLongRunningTransactions[longRunningTransaction] = transaction return transaction } 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 36eeaa0aa2..591651ed6c 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 @@ -39,7 +39,10 @@ class NoopAnalyticsService : AnalyticsService { override fun trackError(throwable: Throwable) = Unit override fun updateSuperProperties(updatedProperties: SuperProperties) = Unit override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction - override fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction = NoopAnalyticsTransaction + override fun startLongRunningTransaction( + longRunningTransaction: AnalyticsLongRunningTransaction, + parentTransaction: AnalyticsTransaction?, + ): AnalyticsTransaction = NoopAnalyticsTransaction override fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? = null override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) = NoopAnalyticsTransaction } 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 9899ced193..274d06f6a5 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 @@ -71,7 +71,10 @@ class FakeAnalyticsService( } override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction - override fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction { + override fun startLongRunningTransaction( + longRunningTransaction: AnalyticsLongRunningTransaction, + parentTransaction: AnalyticsTransaction? + ): AnalyticsTransaction { longRunningTransactions[longRunningTransaction] = NoopAnalyticsTransaction return NoopAnalyticsTransaction } 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 5ed346aa6c..90bbd77e00 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 @@ -9,7 +9,9 @@ package io.element.android.services.analyticsproviders.sentry import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.sentry.ISpan +import io.sentry.ITransaction 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())) @@ -20,5 +22,9 @@ class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTra ) override fun setData(key: String, value: Any) = inner.setData(key, value) override fun isFinished(): Boolean = inner.isFinished - override fun finish() = inner.finish() + override fun finish() { + val name = if (inner is ITransaction) inner.name else inner.operation + Timber.d("Finishing transaction: $name") + inner.finish() + } }