From 739f12d603eac427b3c3a98e13ca1d74b875f9fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 4 Dec 2025 10:27:04 +0100 Subject: [PATCH] Replace `AnalyticsSdkSpanFactory` with `AnalyticsSdkManager`. `AnalyticsSdkManager` also enables and disables Sentry logging in the SDK based on analytics user content. --- ...kFactory.kt => RustAnalyticsSdkManager.kt} | 11 +++++-- .../test/analytics/FakeAnalyticsSdkManager.kt | 24 +++++++++++++++ .../analytics/api/AnalyticsSdkManager.kt | 28 ++++++++++++++++++ .../analytics/api/AnalyticsSdkSpanFactory.kt | 16 ---------- .../analytics/impl/DefaultAnalyticsService.kt | 14 ++++----- .../impl/DefaultAnalyticsServiceTest.kt | 29 ++++++++++++++----- .../test/FakeAnalyticsSdkSpanFactory.kt | 18 ------------ 7 files changed, 88 insertions(+), 52 deletions(-) rename libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/{DefaultAnalyticsSdkFactory.kt => RustAnalyticsSdkManager.kt} (66%) create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/analytics/FakeAnalyticsSdkManager.kt create mode 100644 services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkManager.kt delete mode 100644 services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpanFactory.kt delete mode 100644 services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsSdkSpanFactory.kt diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultAnalyticsSdkFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkManager.kt similarity index 66% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultAnalyticsSdkFactory.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkManager.kt index b5d42a25e6..a74acab683 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultAnalyticsSdkFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkManager.kt @@ -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) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/analytics/FakeAnalyticsSdkManager.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/analytics/FakeAnalyticsSdkManager.kt new file mode 100644 index 0000000000..ab4fd48477 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/analytics/FakeAnalyticsSdkManager.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.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 +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkManager.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkManager.kt new file mode 100644 index 0000000000..bf5e04509c --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkManager.kt @@ -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 +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpanFactory.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpanFactory.kt deleted file mode 100644 index 49558f37b0..0000000000 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpanFactory.kt +++ /dev/null @@ -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 -} 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 3dd9abf5d5..df26766506 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 @@ -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() @@ -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 diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt index d80fb55d3f..7de3d0ac53 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt @@ -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 {} 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 { } + val resetLambda = lambdaRecorder {} + val sdkAnalyticsEnabledLambda = lambdaRecorder {} 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 { _ -> } 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 { _ -> } 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) diff --git a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsSdkSpanFactory.kt b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsSdkSpanFactory.kt deleted file mode 100644 index e01f893d6d..0000000000 --- a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsSdkSpanFactory.kt +++ /dev/null @@ -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 -}