From c6c2f4a267701a555bd97a60ca840190a991ec3c Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 19 Nov 2025 12:42:55 +0100 Subject: [PATCH] Add some performance metrics for Sentry (#5760) - Add `AnalyticsService.startTransaction(...)` to start a logging transaction that can be uploaded to Sentry if the user enabled the analytics upload. - Add `AnalyticsTransaction` wrapper to abstract the Sentry ones. - Added several helper methods to improve the UX around these transactions. - Then measure: - Time until the first sync, and how it ended. - Time until the first rooms are displayed. - Time to load a room or a preview. - Time to load a timeline. --- .../android/appnav/LoggedInFlowNode.kt | 5 ++ .../android/appnav/di/SyncOrchestrator.kt | 12 ++- .../android/appnav/SyncOrchestratorTest.kt | 2 + .../appnav/di/MatrixSessionCacheTest.kt | 2 + .../libraries/matrix/impl/RustMatrixClient.kt | 4 + .../matrix/impl/RustMatrixClientFactory.kt | 1 + .../matrix/impl/room/RustRoomFactory.kt | 90 +++++++++++-------- .../matrix/impl/roomlist/RoomListFactory.kt | 9 ++ .../matrix/impl/sync/RustSyncService.kt | 4 +- .../item/event/TimelineEventContentMapper.kt | 2 +- .../matrix/impl/RustMatrixClientTest.kt | 2 + .../impl/roomlist/RoomListFactoryTest.kt | 2 + .../roomlist/RustBaseRoomListServiceTest.kt | 2 + .../api/AnalyticsLongRunningTransaction.kt | 15 ++++ .../analytics/api/AnalyticsService.kt | 20 +++++ .../analytics/api/NoopAnalyticsTransaction.kt | 17 ++++ .../analytics/impl/DefaultAnalyticsService.kt | 22 +++++ .../analytics/noop/NoopAnalyticsService.kt | 6 ++ .../analytics/test/FakeAnalyticsService.kt | 7 ++ .../api/AnalyticsProvider.kt | 2 + .../api/AnalyticsTransaction.kt | 25 ++++++ .../posthog/PosthogAnalyticsProvider.kt | 3 + .../sentry/SentryAnalyticsProvider.kt | 6 ++ .../sentry/SentryAnalyticsTransaction.kt | 24 +++++ .../test/FakeAnalyticsProvider.kt | 2 + 25 files changed, 245 insertions(+), 41 deletions(-) create mode 100644 services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt create mode 100644 services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt create mode 100644 services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt create mode 100644 services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 4ed9f807a9..946159831b 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -90,6 +90,8 @@ import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.ui.common.nodes.emptyNode +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first @@ -136,6 +138,7 @@ class LoggedInFlowNode( private val appPreferencesStore: AppPreferencesStore, private val buildMeta: BuildMeta, snackbarDispatcher: SnackbarDispatcher, + private val analyticsService: AnalyticsService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -212,6 +215,8 @@ class LoggedInFlowNode( matrixClient.getMaxFileUploadSize() } + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed) + ftueService.state .onEach { ftueState -> when (ftueState) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt index 09536087e0..53ce50a788 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt @@ -18,6 +18,8 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.recordTransaction import io.element.android.services.appnavstate.api.AppForegroundStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview @@ -39,6 +41,7 @@ class SyncOrchestrator( private val appForegroundStateService: AppForegroundStateService, private val networkMonitor: NetworkMonitor, dispatchers: CoroutineDispatchers, + private val analyticsService: AnalyticsService, ) { @AssistedFactory interface Factory { @@ -69,10 +72,13 @@ class SyncOrchestrator( // Perform an initial sync if the sync service is not running, to check whether the homeserver is accessible // Otherwise, if the device is offline the sync service will never start and the SyncState will be Idle, not Offline Timber.tag(tag).d("performing initial sync attempt") - syncService.startSync() + analyticsService.recordTransaction("First sync", "syncService.startSync()") { transaction -> + syncService.startSync() - // Wait until the sync service is not idle, either it will be running or in error/offline state - syncService.syncState.first { it != SyncState.Idle } + // Wait until the sync service is not idle, either it will be running or in error/offline state + val firstState = syncService.syncState.first { it != SyncState.Idle } + transaction.setData("first_sync_state", firstState.name) + } observeStates() } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index 2e3d7bbc0f..7309ca6ac2 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -13,6 +13,7 @@ import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -390,5 +391,6 @@ class SyncOrchestratorTest { networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, dispatchers = testCoroutineDispatchers(), + analyticsService = FakeAnalyticsService(), ) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt index b36ecd1cef..56c20f7a1d 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService 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.auth.FakeMatrixAuthenticationService +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.CoroutineScope @@ -129,6 +130,7 @@ class MatrixSessionCacheTest { appForegroundStateService = FakeAppForegroundStateService(), networkMonitor = FakeNetworkMonitor(), dispatchers = testCoroutineDispatchers(), + analyticsService = FakeAnalyticsService(), ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 445e262604..fca2aaa197 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -76,6 +76,7 @@ import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -131,6 +132,7 @@ class RustMatrixClient( clock: SystemClock, timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, private val featureFlagService: FeatureFlagService, + private val analyticsService: AnalyticsService, ) : MatrixClient { override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) @@ -178,6 +180,7 @@ class RustMatrixClient( roomListFactory = RoomListFactory( innerRoomListService = innerRoomListService, sessionCoroutineScope = sessionCoroutineScope, + analyticsService = analyticsService, ), roomSyncSubscriber = roomSyncSubscriber, ) @@ -212,6 +215,7 @@ class RustMatrixClient( roomMembershipObserver = roomMembershipObserver, roomInfoMapper = roomInfoMapper, featureFlagService = featureFlagService, + analyticsService = analyticsService, ) override val matrixMediaLoader: MatrixMediaLoader = RustMediaLoader( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index f0b2b54b43..4e335ae082 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -114,6 +114,7 @@ class RustMatrixClientFactory( clock = clock, timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, featureFlagService = featureFlagService, + analyticsService = analyticsService, ).also { Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") } 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 564b633e2c..083777a7b7 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,9 @@ 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.AnalyticsService +import io.element.android.services.analytics.api.recordTransaction +import io.element.android.services.analyticsproviders.api.recordChildTransaction import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable @@ -54,6 +57,7 @@ class RustRoomFactory( private val featureFlagService: FeatureFlagService, private val roomMembershipObserver: RoomMembershipObserver, private val roomInfoMapper: RoomInfoMapper, + private val analyticsService: AnalyticsService, ) { private val dispatcher = dispatchers.io.limitedParallelism(1) private val mutex = Mutex() @@ -106,48 +110,64 @@ class RustRoomFactory( Timber.d("Room factory is destroyed, returning null for $roomId") return@withContext null } - val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null + + val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withLock null if (sdkRoom.membership() == Membership.JOINED) { - val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) - // Init the live timeline in the SDK from the Room - val timeline = sdkRoom.timelineWithConfiguration( - TimelineConfiguration( - focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), - filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, - internalIdPrefix = "live", - dateDividerMode = DateDividerMode.DAILY, - trackReadReceipts = true, - reportUtds = true, - ) - ) + analyticsService.recordTransaction( + name = "Get joined room", + operation = "RustRoomFactory.getJoinedRoomOrPreview", + ) { transaction -> + val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) + // Init the live timeline in the SDK from the Room + val timeline = transaction.recordChildTransaction( + operation = "sdkRoom.timelineWithConfiguration", + description = "Get timeline from the SDK", + ) { + sdkRoom.timelineWithConfiguration( + TimelineConfiguration( + focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), + filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, + internalIdPrefix = "live", + dateDividerMode = DateDividerMode.DAILY, + trackReadReceipts = true, + reportUtds = true, + ) + ) + } - GetRoomResult.Joined( - JoinedRustRoom( - baseRoom = getBaseRoom(sdkRoom), - notificationSettingsService = notificationSettingsService, - roomContentForwarder = roomContentForwarder, - liveInnerTimeline = timeline, - coroutineDispatchers = dispatchers, - systemClock = systemClock, - featureFlagService = featureFlagService, + GetRoomResult.Joined( + JoinedRustRoom( + baseRoom = getBaseRoom(sdkRoom), + notificationSettingsService = notificationSettingsService, + roomContentForwarder = roomContentForwarder, + liveInnerTimeline = timeline, + coroutineDispatchers = dispatchers, + systemClock = systemClock, + featureFlagService = featureFlagService, + ) ) - ) - } else { - val preview = try { - sdkRoom.previewRoom(via = serverNames) - } catch (e: Exception) { - Timber.e(e, "Failed to get room preview for $roomId") - return@withContext null } + } else { + analyticsService.recordTransaction( + name = "Get preview of room", + operation = "RustRoomFactory.getJoinedRoomOrPreview", + ) { + val preview = try { + sdkRoom.previewRoom(via = serverNames) + } catch (e: Exception) { + Timber.e(e, "Failed to get room preview for $roomId") + return@recordTransaction null + } - GetRoomResult.NotJoined( - NotJoinedRustRoom( - sessionId = sessionId, - localRoom = getBaseRoom(sdkRoom), - previewInfo = RoomPreviewInfoMapper.map(preview.info()), + GetRoomResult.NotJoined( + NotJoinedRustRoom( + sessionId = sessionId, + localRoom = getBaseRoom(sdkRoom), + previewInfo = RoomPreviewInfoMapper.map(preview.info()), + ) ) - ) + } } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt index 5632906cc8..b0d545e2a1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt @@ -12,6 +12,8 @@ import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -36,6 +38,7 @@ private val ROOM_LIST_RUST_FILTERS = listOf( internal class RoomListFactory( private val innerRoomListService: RoomListService, private val sessionCoroutineScope: CoroutineScope, + private val analyticsService: AnalyticsService, ) { private val roomSummaryDetailsFactory: RoomSummaryFactory = RoomSummaryFactory() @@ -59,6 +62,8 @@ internal class RoomListFactory( val loadedPages = MutableStateFlow(1) var innerRoomList: InnerRoomList? = null + val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow") + coroutineScope.launch(coroutineContext) { innerRoomList = innerProvider() innerRoomList.let { innerRoomList -> @@ -67,6 +72,10 @@ internal class RoomListFactory( roomListDynamicEvents = dynamicEvents, initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS), ).onEach { update -> + if (!firstRoomsTransaction.isFinished()) { + analyticsService.stopLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed) + firstRoomsTransaction.finish() + } processor.postUpdate(update) }.launchIn(this) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt index d5e8ce4b4c..f7732cd9b7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt @@ -30,7 +30,7 @@ import org.matrix.rustcomponents.sdk.SyncService as InnerSyncService class RustSyncService( private val inner: InnerSyncService, private val dispatcher: CoroutineDispatcher, - sessionCoroutineScope: CoroutineScope + sessionCoroutineScope: CoroutineScope, ) : SyncService { private val isServiceReady = AtomicBoolean(true) @@ -71,10 +71,10 @@ class RustSyncService( override val syncState: StateFlow = inner.stateFlow() .map(SyncServiceState::toSyncState) + .distinctUntilChanged() .onEach { state -> Timber.i("Sync state=$state") } - .distinctUntilChanged() .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 31bd59668d..dc60afc596 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -210,7 +210,7 @@ private fun RustOtherState.map(): OtherState { RustOtherState.RoomEncryption -> OtherState.RoomEncryption RustOtherState.RoomGuestAccess -> OtherState.RoomGuestAccess RustOtherState.RoomHistoryVisibility -> OtherState.RoomHistoryVisibility - RustOtherState.RoomJoinRules -> OtherState.RoomJoinRules + is RustOtherState.RoomJoinRules -> OtherState.RoomJoinRules is RustOtherState.RoomName -> OtherState.RoomName(name) is RustOtherState.RoomPinnedEvents -> OtherState.RoomPinnedEvents(change.map()) is RustOtherState.RoomPowerLevels -> OtherState.RoomUserPowerLevels(users) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index 561e250ee4..06bd91eb24 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -116,5 +117,6 @@ class RustMatrixClientTest { clock = FakeSystemClock(), timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), featureFlagService = FakeFeatureFlagService(), + analyticsService = FakeAnalyticsService(), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt index 9906f1922e..ec8053e940 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.roomlist import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomList import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService +import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.test.runTest import org.junit.Ignore import org.junit.Test @@ -22,6 +23,7 @@ class RoomListFactoryTest { val sut = RoomListFactory( innerRoomListService = FakeFfiRoomListService(), sessionCoroutineScope = backgroundScope, + analyticsService = FakeAnalyticsService(), ) sut.createRoomList( pageSize = 10, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt index ec21ec3515..9c8c0ddb69 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt @@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -52,6 +53,7 @@ private fun TestScope.createRustRoomListService( roomListFactory = RoomListFactory( innerRoomListService = roomListService, sessionCoroutineScope = backgroundScope, + analyticsService = FakeAnalyticsService(), ), roomSyncSubscriber = RoomSyncSubscriber( roomListService = roomListService, 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 new file mode 100644 index 0000000000..e465e2450a --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt @@ -0,0 +1,15 @@ +/* + * 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 + +sealed class AnalyticsLongRunningTransaction( + val name: String, + val operation: String?, +) { + data object FirstRoomsDisplayed : AnalyticsLongRunningTransaction("First rooms displayed after login or restoration", 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 82d27103b5..a936f47be1 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 @@ -9,6 +9,7 @@ package io.element.android.services.analytics.api import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.services.analyticsproviders.api.trackers.AnalyticsTracker import io.element.android.services.analyticsproviders.api.trackers.ErrorTracker import kotlinx.coroutines.flow.Flow @@ -48,4 +49,23 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker { * Update analyticsId from the AccountData. */ suspend fun setAnalyticsId(analyticsId: String) + + /** + * Starts a transaction to measure the performance of an operation. + */ + fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction + + fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) + + fun stopLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) +} + +inline fun AnalyticsService.recordTransaction(name: String, operation: String, block: (AnalyticsTransaction) -> T): T { + val transaction = startTransaction(name, operation) + try { + val result = block(transaction) + return result + } finally { + transaction.finish() + } } diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt new file mode 100644 index 0000000000..2b18f8408c --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt @@ -0,0 +1,17 @@ +/* + * 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 + +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction + +object NoopAnalyticsTransaction : AnalyticsTransaction { + override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction + override fun setData(key: String, value: Any) {} + override fun isFinished(): Boolean = true + override fun finish() {} +} 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 45065ad02a..e773212f33 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 @@ -19,15 +19,19 @@ import im.vector.app.features.analytics.plan.UserProperties import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analytics.impl.log.analyticsTag import io.element.android.services.analytics.impl.store.AnalyticsStore import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean @SingleIn(AppScope::class) @@ -40,6 +44,8 @@ class DefaultAnalyticsService( private val coroutineScope: CoroutineScope, private val sessionObserver: SessionObserver, ) : AnalyticsService, SessionListener { + private val pendingLongRunningTransactions = ConcurrentHashMap() + // Cache for the store values private val userConsent = AtomicBoolean(false) @@ -138,4 +144,20 @@ class DefaultAnalyticsService( analyticsProviders.onEach { it.trackError(throwable) } } } + + override fun startTransaction(name: String, operation: String?): AnalyticsTransaction { + return if (userConsent.get()) { + analyticsProviders.firstNotNullOfOrNull { it.startTransaction(name, operation) } + } else { + null + } ?: NoopAnalyticsTransaction + } + + override fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) { + pendingLongRunningTransactions[longRunningTransaction] = startTransaction(longRunningTransaction.name, longRunningTransaction.operation) + } + + override fun stopLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) { + pendingLongRunningTransactions.remove(longRunningTransaction)?.finish() + } } 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 ac51e24c7b..a2175231a4 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 @@ -15,8 +15,11 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -35,4 +38,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 startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {} + override fun stopLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {} } 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 e7cb104d13..c83e0119ed 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 @@ -12,8 +12,11 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -65,4 +68,8 @@ class FakeAnalyticsService( override fun updateSuperProperties(updatedProperties: SuperProperties) { // No op } + + override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction + override fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {} + override fun stopLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {} } 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 088714c575..f90d924c81 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 @@ -20,4 +20,6 @@ interface AnalyticsProvider : AnalyticsTracker, ErrorTracker { fun init() fun stop() + + fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction? } diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt new file mode 100644 index 0000000000..8297055f96 --- /dev/null +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt @@ -0,0 +1,25 @@ +/* + * 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 + +interface AnalyticsTransaction { + fun startChild(operation: String, description: String? = null): AnalyticsTransaction + fun setData(key: String, value: Any) + fun isFinished(): Boolean + fun finish() +} + +inline fun AnalyticsTransaction.recordChildTransaction(operation: String, description: String? = null, block: (AnalyticsTransaction) -> T): T { + val child = startChild(operation, description) + try { + val result = block(child) + return result + } finally { + child.finish() + } +} 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 b47d710f56..9185b906c4 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 @@ -17,6 +17,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.services.analyticsproviders.posthog.log.analyticsTag import timber.log.Timber @@ -122,6 +123,8 @@ class PosthogAnalyticsProvider( } return withSuperProperties.takeIf { it.isEmpty().not() } } + + override fun startTransaction(name: String, operation: 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 afe9740b71..af34522c53 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 @@ -20,6 +20,7 @@ import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.services.analyticsproviders.sentry.log.analyticsTag import io.sentry.Breadcrumb import io.sentry.Sentry @@ -51,6 +52,7 @@ class SentryAnalyticsProvider( options.isEnableUserInteractionTracing = true options.environment = buildMeta.buildType.toSentryEnv() } + Timber.tag(analyticsTag.value).d("Sentry was initialized correctly") } override fun stop() { @@ -87,6 +89,10 @@ class SentryAnalyticsProvider( override fun trackError(throwable: Throwable) { Sentry.captureException(throwable) } + + override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? { + return SentryAnalyticsTransaction(name, operation) + } } private fun BuildType.toSentryEnv() = when (this) { 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 new file mode 100644 index 0000000000..5ed346aa6c --- /dev/null +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt @@ -0,0 +1,24 @@ +/* + * 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.sentry + +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction +import io.sentry.ISpan +import io.sentry.Sentry + +class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction { + constructor(name: String, operation: String?) : this(Sentry.startTransaction(name, operation.orEmpty())) + private val inner = span + + override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction( + inner.startChild(operation, description) + ) + override fun setData(key: String, value: Any) = inner.setData(key, value) + override fun isFinished(): Boolean = inner.isFinished + override fun finish() = 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 c1e563db77..9b0374bd9b 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 @@ -13,6 +13,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.tests.testutils.lambda.lambdaError class FakeAnalyticsProvider( @@ -32,4 +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 }