Adjust metrics to the new specifications (#5937)

* Add `AnalyticsTransactions` with a set of `TransactionDefinition` items matching those in the user story

* Use that for `AnalyticsLongRunningTransactions`, make sure we send the right fields (name, operation, description)

* Add `AnalyticsSendMessageWatcher` to track how long it takes for an event to be sent and for us to get a call back for that from sync

* Add `Noop` implementation for enterprise
This commit is contained in:
Jorge Martin Espinosa
2026-01-05 16:23:26 +01:00
committed by GitHub
parent bc62d4c8ba
commit 71031008dd
33 changed files with 443 additions and 48 deletions

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.services.analytics.impl.watchers
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsTransactions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
private const val TAG = "SendMessageWatcher"
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class DefaultAnalyticsSendMessageWatcher(
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
@RoomCoroutineScope private val coroutineScope: CoroutineScope,
) : AnalyticsSendMessageWatcher {
private val pendingEvents = ConcurrentHashMap<TransactionId, AnalyticsTransaction>()
private var sendQueueWatchJob: Job? = null
@OptIn(ExperimentalCoroutinesApi::class)
override fun start() {
Timber.tag(TAG).d("Starting SendMessageWatcher")
sendQueueWatchJob?.cancel()
sendQueueWatchJob = room.subscribeToSendQueueUpdates()
.onEach { update ->
// We received a new local event
when (update) {
is SendQueueUpdate.NewLocalEvent -> {
Timber.tag(TAG).d("Event with transaction id ${update.transactionId} sent")
watch(update.transactionId)
}
is SendQueueUpdate.SentEvent -> {
val pendingTransaction = pendingEvents.remove(update.transactionId)
if (pendingTransaction != null) {
Timber.tag(TAG).d("Sent event with transaction id ${update.transactionId} received in sync")
pendingTransaction.finish()
}
}
else -> Unit
}
}
.launchIn(coroutineScope)
}
override fun stop() {
Timber.tag(TAG).d("Stopping SendMessageWatcher")
sendQueueWatchJob?.cancel()
sendQueueWatchJob = null
pendingEvents.clear()
}
private fun watch(transactionId: TransactionId) {
pendingEvents[transactionId] = with(AnalyticsTransactions.sendMessage) {
analyticsService.startTransaction(
name = name,
operation = operation,
description = description,
)
}
}
}

View File

@@ -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(

View File

@@ -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()
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.services.analytics.impl.watchers
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.api.NoopAnalyticsTransaction
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultAnalyticsSendMessageWatcherTest {
@Test
fun `test start listens to send queue updates`() = runTest {
val mockedTransaction = mockk<NoopAnalyticsTransaction>(relaxed = true)
val startTransactionRecorder = lambdaRecorder { _: String, _: String?, _: String? -> mockedTransaction }
val room = FakeJoinedRoom()
val analyticsService = FakeAnalyticsService(startTransactionLambda = startTransactionRecorder)
val watcher = createDefaultAnalyticsSendMessageWatcher(room = room, analyticsService = analyticsService)
// When we start listening, we don't trigger any analyticsService.startTransaction calls
watcher.start()
runCurrent()
startTransactionRecorder.assertions().isNeverCalled()
// When we receive a new local event, we start a new transaction for it
room.givenSendQueueUpdate(SendQueueUpdate.NewLocalEvent(A_TRANSACTION_ID))
runCurrent()
startTransactionRecorder.assertions().isCalledOnce()
// And we receive an 'event sent' update with the event's id, we finish the transaction
room.givenSendQueueUpdate(SendQueueUpdate.SentEvent(A_TRANSACTION_ID, AN_EVENT_ID))
runCurrent()
verify { mockedTransaction.finish() }
// We also stop the watcher for cleanup
watcher.stop()
}
private fun TestScope.createDefaultAnalyticsSendMessageWatcher(
room: FakeJoinedRoom = FakeJoinedRoom(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = DefaultAnalyticsSendMessageWatcher(
room = room,
analyticsService = analyticsService,
coroutineScope = backgroundScope,
)
}