Replace AnalyticsSdkSpanFactory with AnalyticsSdkManager.

`AnalyticsSdkManager` also enables and disables Sentry logging in the SDK based on analytics user content.
This commit is contained in:
Jorge Martín
2025-12-04 10:27:04 +01:00
committed by Jorge Martin Espinosa
parent b31267d7c7
commit 739f12d603
7 changed files with 88 additions and 52 deletions

View File

@@ -9,12 +9,17 @@ package io.element.android.libraries.matrix.impl.analytics
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.services.analytics.api.AnalyticsSdkManager
import io.element.android.services.analytics.api.AnalyticsSdkSpan
import io.element.android.services.analytics.api.AnalyticsSdkSpanFactory
import org.matrix.rustcomponents.sdk.enableSentryLogging
@ContributesBinding(AppScope::class)
class DefaultAnalyticsSdkFactory : AnalyticsSdkSpanFactory {
override fun create(name: String, parentTraceId: String?): AnalyticsSdkSpan {
class RustAnalyticsSdkManager : AnalyticsSdkManager {
override fun enableSdkAnalytics(enabled: Boolean) {
enableSentryLogging(enabled)
}
override fun startSpan(name: String, parentTraceId: String?): AnalyticsSdkSpan {
return RustAnalyticsSdkSpan(name = name, parentTraceId = parentTraceId)
}

View File

@@ -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.libraries.matrix.test.analytics
import io.element.android.services.analytics.api.AnalyticsSdkManager
import io.element.android.services.analytics.api.AnalyticsSdkSpan
import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan
import io.element.android.tests.testutils.lambda.lambdaError
class FakeAnalyticsSdkManager(
private val enableSdkAnalyticsLambda: ((Boolean) -> Unit) = { lambdaError() },
) : AnalyticsSdkManager {
override fun enableSdkAnalytics(enabled: Boolean) {
enableSdkAnalyticsLambda(enabled)
}
override fun startSpan(name: String, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan
override fun bridge(parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan
}

View File

@@ -0,0 +1,28 @@
/*
* 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
/**
* Manager to handle SDK analytics (e.g., Sentry).
*/
interface AnalyticsSdkManager {
/**
* Enable or disable SDK analytics.
*/
fun enableSdkAnalytics(enabled: Boolean)
/**
* Start a new span with the given [name], using [parentTraceId] to optionally attach it to a parent transaction.
*/
fun startSpan(name: String, parentTraceId: String? = null): AnalyticsSdkSpan
/**
* Create a 'bridge' span optionally linking it to a parent trace via [parentTraceId].
*/
fun bridge(parentTraceId: String? = null): AnalyticsSdkSpan
}

View File

@@ -1,16 +0,0 @@
/*
* 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
interface AnalyticsSdkSpanFactory {
/** Create an SDK span with the provided [name] and optional [parentTraceId]. */
fun create(name: String, parentTraceId: String?): AnalyticsSdkSpan
/** Create a bridge span which will join our tracing spans to the SDK ones while it's active. */
fun bridge(parentTraceId: String?): AnalyticsSdkSpan
}

View File

@@ -20,8 +20,8 @@ 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.AnalyticsSdkManager
import io.element.android.services.analytics.api.AnalyticsSdkSpan
import io.element.android.services.analytics.api.AnalyticsSdkSpanFactory
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan
import io.element.android.services.analytics.api.NoopAnalyticsTransaction
@@ -42,11 +42,9 @@ import java.util.concurrent.atomic.AtomicBoolean
class DefaultAnalyticsService(
private val analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider>,
private val analyticsStore: AnalyticsStore,
// private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
private val analyticsSdkSpanFactory: AnalyticsSdkSpanFactory,
private val analyticsSdkManager: AnalyticsSdkManager,
) : AnalyticsService, SessionListener {
private val pendingLongRunningTransactions = ConcurrentHashMap<AnalyticsLongRunningTransaction, AnalyticsTransaction>()
@@ -72,6 +70,7 @@ class DefaultAnalyticsService(
override suspend fun setUserConsent(userConsent: Boolean) {
Timber.tag(analyticsTag.value).d("setUserConsent($userConsent)")
analyticsStore.setUserConsent(userConsent)
analyticsSdkManager.enableSdkAnalytics(enabled = userConsent)
}
override suspend fun setDidAskUserConsent() {
@@ -88,6 +87,7 @@ class DefaultAnalyticsService(
// Delete the store when the last session is deleted
if (wasLastSession) {
analyticsStore.reset()
analyticsSdkManager.enableSdkAnalytics(false)
}
}
@@ -179,9 +179,9 @@ class DefaultAnalyticsService(
override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan {
return if (userConsent.get()) {
if (name != null) {
analyticsSdkSpanFactory.create(name, parentTraceId)
analyticsSdkManager.startSpan(name, parentTraceId)
} else {
analyticsSdkSpanFactory.bridge(parentTraceId)
analyticsSdkManager.bridge(parentTraceId)
}.apply { enter() }
} else {
NoopAnalyticsSdkSpan

View File

@@ -17,11 +17,11 @@ import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.analytics.plan.PollEnd
import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.matrix.test.analytics.FakeAnalyticsSdkManager
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import io.element.android.services.analytics.impl.store.AnalyticsStore
import io.element.android.services.analytics.impl.store.FakeAnalyticsStore
import io.element.android.services.analytics.test.FakeAnalyticsSdkSpanFactory
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.test.FakeAnalyticsProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -33,6 +33,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -127,17 +128,20 @@ class DefaultAnalyticsServiceTest {
}
@Test
fun `setUserConsent is sent to the store`() = runTest {
fun `setUserConsent is sent to the store and the SDK`() = runTest {
val sdkAnalyticsEnabledLambda = lambdaRecorder<Boolean, Unit> {}
val store = FakeAnalyticsStore()
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = store,
sdkAnalyticsManager = FakeAnalyticsSdkManager(sdkAnalyticsEnabledLambda),
)
assertThat(store.userConsentFlow.first()).isFalse()
assertThat(sut.userConsentFlow.first()).isFalse()
sut.setUserConsent(true)
assertThat(store.userConsentFlow.first()).isTrue()
assertThat(sut.userConsentFlow.first()).isTrue()
sdkAnalyticsEnabledLambda.assertions().isCalledOnce().with(value(true))
}
@Test
@@ -170,16 +174,19 @@ class DefaultAnalyticsServiceTest {
@Test
fun `when the last session is deleted, the store is reset`() = runTest {
val resetLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Unit> {}
val sdkAnalyticsEnabledLambda = lambdaRecorder<Boolean, Unit> {}
val store = FakeAnalyticsStore(
resetLambda = resetLambda,
)
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = store,
sdkAnalyticsManager = FakeAnalyticsSdkManager(sdkAnalyticsEnabledLambda),
)
sut.onSessionDeleted("userId", true)
resetLambda.assertions().isCalledOnce()
sdkAnalyticsEnabledLambda.assertions().isCalledOnce().with(value(false))
}
@Test
@@ -235,7 +242,6 @@ class DefaultAnalyticsServiceTest {
fun `when consent is provided, updateUserProperties is sent to the provider`() = runTest {
val updateUserPropertiesLambda = lambdaRecorder<UserProperties, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = { },
@@ -252,7 +258,6 @@ class DefaultAnalyticsServiceTest {
fun `when super properties are updated, updateSuperProperties is sent to the provider`() = runTest {
val updateSuperPropertiesLambda = lambdaRecorder<SuperProperties, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = { },
@@ -265,8 +270,15 @@ class DefaultAnalyticsServiceTest {
updateSuperPropertiesLambda.assertions().isCalledOnce().with(value(aSuperProperty))
}
private suspend fun createDefaultAnalyticsService(
coroutineScope: CoroutineScope,
@Test
fun `startSdkSpan returns a span from the AnalyticsSdkManager`() = runTest {
val sut = createDefaultAnalyticsService()
val span = sut.enterSdkSpan("spanName", "parentTraceId")
assertThat(span).isNotNull()
}
private suspend fun TestScope.createDefaultAnalyticsService(
coroutineScope: CoroutineScope = backgroundScope,
analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider> = setOf(
FakeAnalyticsProvider(
stopLambda = { },
@@ -274,12 +286,13 @@ class DefaultAnalyticsServiceTest {
),
analyticsStore: AnalyticsStore = FakeAnalyticsStore(),
sessionObserver: SessionObserver = NoOpSessionObserver(),
sdkAnalyticsManager: FakeAnalyticsSdkManager = FakeAnalyticsSdkManager(enableSdkAnalyticsLambda = {}),
) = DefaultAnalyticsService(
analyticsProviders = analyticsProviders,
analyticsStore = analyticsStore,
coroutineScope = coroutineScope,
sessionObserver = sessionObserver,
analyticsSdkSpanFactory = FakeAnalyticsSdkSpanFactory(),
analyticsSdkManager = sdkAnalyticsManager,
).also {
// Wait for the service to be ready
delay(1)

View File

@@ -1,18 +0,0 @@
/*
* 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
import io.element.android.services.analytics.api.AnalyticsSdkSpan
import io.element.android.services.analytics.api.AnalyticsSdkSpanFactory
import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan
class FakeAnalyticsSdkSpanFactory : AnalyticsSdkSpanFactory {
override fun create(name: String, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan
override fun bridge(parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan
}