Add tests for SentryAnalyticsProvider

This commit is contained in:
Jorge Martín
2025-12-17 08:53:01 +01:00
committed by Jorge Martin Espinosa
parent 4ef0dfed8c
commit 9a9e84f6c8
3 changed files with 203 additions and 18 deletions

View File

@@ -2,6 +2,7 @@ import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
@@ -54,4 +55,8 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.services.analyticsproviders.api)
implementation(projects.services.appnavstate.api)
testCommonDependencies(libs, false)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.appnavstate.test)
}

View File

@@ -9,6 +9,7 @@
package io.element.android.services.analyticsproviders.sentry
import android.content.Context
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
@@ -31,6 +32,7 @@ import io.sentry.Breadcrumb
import io.sentry.Sentry
import io.sentry.SentryOptions
import io.sentry.android.core.SentryAndroid
import io.sentry.protocol.SentryTransaction
import kotlinx.coroutines.runBlocking
import timber.log.Timber
@@ -56,24 +58,7 @@ class SentryAnalyticsProvider(
SentryAndroid.init(context) { options ->
options.dsn = dsn
options.beforeSendTransaction = SentryOptions.BeforeSendTransactionCallback { transaction, _ ->
// Ensure we'll never upload any session ids
val possibleSessionIds = transaction.extras?.filter { (it.value as? String)?.startsWith("@") == true }.orEmpty()
for (invalidExtra in possibleSessionIds) {
transaction.extras?.remove(invalidExtra.key)
}
val sessionId = appNavigationStateService.appNavigationState.value.navigationState.currentSessionId()
if (sessionId != null) {
// This runs in a separate thread, so although using `runBlocking` is not great, at least it shouldn't freeze the app
// Also, the method is fairly quick, so the blocking shouldn't take longer than a few ms
val databaseSizes = runBlocking { getDatabaseSizesUseCase(sessionId) }.getOrNull()
databaseSizes?.stateStore?.let { transaction.setExtra(AnalyticsUserData.STATE_STORE_SIZE, it.into(ByteUnit.MB)) }
databaseSizes?.eventCacheStore?.let { transaction.setExtra(AnalyticsUserData.EVENT_CACHE_SIZE, it.into(ByteUnit.MB)) }
databaseSizes?.mediaStore?.let { transaction.setExtra(AnalyticsUserData.MEDIA_STORE_SIZE, it.into(ByteUnit.MB)) }
databaseSizes?.cryptoStore?.let { transaction.setExtra(AnalyticsUserData.CRYPTO_STORE_SIZE, it.into(ByteUnit.MB)) }
}
transaction
prepareTransactionBeforeSend(transaction)
}
options.tracesSampleRate = 1.0
options.isEnableUserInteractionTracing = true
@@ -128,6 +113,28 @@ class SentryAnalyticsProvider(
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? {
return SentryAnalyticsTransaction(name, operation)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun prepareTransactionBeforeSend(transaction: SentryTransaction): SentryTransaction {
// Ensure we'll never upload any session ids
val possibleSessionIds = transaction.extras?.filter { (it.value as? String)?.startsWith("@") == true }.orEmpty()
for (invalidExtra in possibleSessionIds) {
transaction.removeExtra(invalidExtra.key)
}
val sessionId = appNavigationStateService.appNavigationState.value.navigationState.currentSessionId()
if (sessionId != null) {
// This runs in a separate thread, so although using `runBlocking` is not great, at least it shouldn't freeze the app
// Also, the method is fairly quick, so the blocking shouldn't take longer than a few ms
val databaseSizes = runBlocking { getDatabaseSizesUseCase(sessionId) }.getOrNull()
databaseSizes?.stateStore?.let { transaction.setExtra(AnalyticsUserData.STATE_STORE_SIZE, it.into(ByteUnit.MB)) }
databaseSizes?.eventCacheStore?.let { transaction.setExtra(AnalyticsUserData.EVENT_CACHE_SIZE, it.into(ByteUnit.MB)) }
databaseSizes?.mediaStore?.let { transaction.setExtra(AnalyticsUserData.MEDIA_STORE_SIZE, it.into(ByteUnit.MB)) }
databaseSizes?.cryptoStore?.let { transaction.setExtra(AnalyticsUserData.CRYPTO_STORE_SIZE, it.into(ByteUnit.MB)) }
}
return transaction
}
}
private fun BuildType.toSentryEnv() = when (this) {

View File

@@ -0,0 +1,173 @@
/*
* 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 androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
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.libraries.core.data.megaBytes
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.sentry.Sentry
import io.sentry.SentryTracer
import io.sentry.protocol.SentryId
import io.sentry.protocol.SentryTransaction
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SentryAnalyticsProviderTest {
@Test
fun `init enables Sentry`() {
createSentryAnalyticsProvider().run {
init()
}
assertThat(Sentry.isEnabled()).isTrue()
}
@Test
fun `stop disables Sentry`() {
createSentryAnalyticsProvider().run {
init()
stop()
}
assertThat(Sentry.isEnabled()).isFalse()
}
@Test
fun `capture adds a breadcrumb`() {
createSentryAnalyticsProvider().run {
init()
capture(object : VectorAnalyticsEvent {
override fun getName(): String = "Test"
override fun getProperties(): Map<String, Any?>? = null
})
}
assertThat(Sentry.getCurrentScopes().scope.breadcrumbs.isNotEmpty()).isTrue()
}
@Test
fun `screen adds a breadcrumb`() {
createSentryAnalyticsProvider().run {
init()
screen(object : VectorAnalyticsScreen {
override fun getName(): String = "Test"
override fun getProperties(): Map<String, Any>? = null
})
}
assertThat(Sentry.getCurrentScopes().scope.breadcrumbs.isNotEmpty()).isTrue()
}
@Test
fun `updateUserProperties and updateSuperProperties do nothing`() {
createSentryAnalyticsProvider().run {
init()
updateUserProperties(UserProperties())
updateSuperProperties(SuperProperties())
}
val scope = Sentry.getCurrentScopes().scope
assertThat(scope.extras.isEmpty()).isTrue()
assertThat(scope.tags.isEmpty()).isTrue()
assertThat(scope.contexts.isEmpty()).isTrue()
}
@Test
fun `addExtraData adds a global extra`() {
createSentryAnalyticsProvider().run {
init()
addExtraData("foo", "bar")
}
val scope = Sentry.getCurrentScopes().scope
assertThat(scope.extras.get("foo")).isEqualTo("bar")
}
@Test
fun `addIndexableData adds a global tag`() {
createSentryAnalyticsProvider().run {
init()
addIndexableData("foo", "bar")
}
val scope = Sentry.getCurrentScopes().scope
assertThat(scope.tags.get("foo")).isEqualTo("bar")
}
@Test
fun `trackError adds a throwable to the global scope`() {
var initialLastId: SentryId? = null
createSentryAnalyticsProvider().run {
init()
initialLastId = Sentry.getLastEventId()
trackError(IllegalStateException("foo"))
}
assertThat(Sentry.getLastEventId()).isNotEqualTo(initialLastId)
}
@Test
fun `startTransaction starts a SentryAnalyticsTransaction`() {
val transaction = createSentryAnalyticsProvider().run {
init()
startTransaction("foo")
}
assertThat(transaction).isNotNull()
assertThat(transaction).isInstanceOf(SentryAnalyticsTransaction::class.java)
}
@Test
fun `prepareTransactionBeforeSend removes unwanted data and adds DB size extras`() {
createSentryAnalyticsProvider(
getDatabaseSizesUseCase = GetDatabaseSizesUseCase {
Result.success(
SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 11.megaBytes, mediaStore = 12.megaBytes, cryptoStore = 13.megaBytes)
)
},
appNavigationStateService = FakeAppNavigationStateService(
MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true))
)
).run {
init()
val transaction = SentryTransaction(Sentry.startTransaction("foo", "bar") as SentryTracer)
// Add a user id value
transaction.setExtra("user", "@some:user")
val result = prepareTransactionBeforeSend(transaction)
// The user id value should have been removed
assertThat(result.getExtra("user")).isNull()
// The DB sizes should be included
assertThat(result.getExtra(AnalyticsUserData.STATE_STORE_SIZE)).isEqualTo(10)
assertThat(result.getExtra(AnalyticsUserData.EVENT_CACHE_SIZE)).isEqualTo(11)
assertThat(result.getExtra(AnalyticsUserData.MEDIA_STORE_SIZE)).isEqualTo(12)
assertThat(result.getExtra(AnalyticsUserData.CRYPTO_STORE_SIZE)).isEqualTo(13)
}
}
private fun createSentryAnalyticsProvider(
buildMeta: BuildMeta = aBuildMeta(),
getDatabaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService(),
) = SentryAnalyticsProvider(
context = InstrumentationRegistry.getInstrumentation().targetContext,
buildMeta = buildMeta,
getDatabaseSizesUseCase = getDatabaseSizesUseCase,
appNavigationStateService = appNavigationStateService,
)
}