Replace AnalyticsSdkSpanFactory with AnalyticsSdkManager.
`AnalyticsSdkManager` also enables and disables Sentry logging in the SDK based on analytics user content.
This commit is contained in:
parent
b31267d7c7
commit
739f12d603
7 changed files with 88 additions and 52 deletions
|
|
@ -9,12 +9,17 @@ package io.element.android.libraries.matrix.impl.analytics
|
||||||
|
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
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.AnalyticsSdkSpan
|
||||||
import io.element.android.services.analytics.api.AnalyticsSdkSpanFactory
|
import org.matrix.rustcomponents.sdk.enableSentryLogging
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultAnalyticsSdkFactory : AnalyticsSdkSpanFactory {
|
class RustAnalyticsSdkManager : AnalyticsSdkManager {
|
||||||
override fun create(name: String, parentTraceId: String?): AnalyticsSdkSpan {
|
override fun enableSdkAnalytics(enabled: Boolean) {
|
||||||
|
enableSentryLogging(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startSpan(name: String, parentTraceId: String?): AnalyticsSdkSpan {
|
||||||
return RustAnalyticsSdkSpan(name = name, parentTraceId = parentTraceId)
|
return RustAnalyticsSdkSpan(name = name, parentTraceId = parentTraceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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.SessionListener
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
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.AnalyticsSdkSpan
|
||||||
import io.element.android.services.analytics.api.AnalyticsSdkSpanFactory
|
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan
|
import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan
|
||||||
import io.element.android.services.analytics.api.NoopAnalyticsTransaction
|
import io.element.android.services.analytics.api.NoopAnalyticsTransaction
|
||||||
|
|
@ -42,11 +42,9 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||||
class DefaultAnalyticsService(
|
class DefaultAnalyticsService(
|
||||||
private val analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider>,
|
private val analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider>,
|
||||||
private val analyticsStore: AnalyticsStore,
|
private val analyticsStore: AnalyticsStore,
|
||||||
// private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory,
|
@AppCoroutineScope private val coroutineScope: CoroutineScope,
|
||||||
@AppCoroutineScope
|
|
||||||
private val coroutineScope: CoroutineScope,
|
|
||||||
private val sessionObserver: SessionObserver,
|
private val sessionObserver: SessionObserver,
|
||||||
private val analyticsSdkSpanFactory: AnalyticsSdkSpanFactory,
|
private val analyticsSdkManager: AnalyticsSdkManager,
|
||||||
) : AnalyticsService, SessionListener {
|
) : AnalyticsService, SessionListener {
|
||||||
private val pendingLongRunningTransactions = ConcurrentHashMap<AnalyticsLongRunningTransaction, AnalyticsTransaction>()
|
private val pendingLongRunningTransactions = ConcurrentHashMap<AnalyticsLongRunningTransaction, AnalyticsTransaction>()
|
||||||
|
|
||||||
|
|
@ -72,6 +70,7 @@ class DefaultAnalyticsService(
|
||||||
override suspend fun setUserConsent(userConsent: Boolean) {
|
override suspend fun setUserConsent(userConsent: Boolean) {
|
||||||
Timber.tag(analyticsTag.value).d("setUserConsent($userConsent)")
|
Timber.tag(analyticsTag.value).d("setUserConsent($userConsent)")
|
||||||
analyticsStore.setUserConsent(userConsent)
|
analyticsStore.setUserConsent(userConsent)
|
||||||
|
analyticsSdkManager.enableSdkAnalytics(enabled = userConsent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setDidAskUserConsent() {
|
override suspend fun setDidAskUserConsent() {
|
||||||
|
|
@ -88,6 +87,7 @@ class DefaultAnalyticsService(
|
||||||
// Delete the store when the last session is deleted
|
// Delete the store when the last session is deleted
|
||||||
if (wasLastSession) {
|
if (wasLastSession) {
|
||||||
analyticsStore.reset()
|
analyticsStore.reset()
|
||||||
|
analyticsSdkManager.enableSdkAnalytics(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,9 +179,9 @@ class DefaultAnalyticsService(
|
||||||
override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan {
|
override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan {
|
||||||
return if (userConsent.get()) {
|
return if (userConsent.get()) {
|
||||||
if (name != null) {
|
if (name != null) {
|
||||||
analyticsSdkSpanFactory.create(name, parentTraceId)
|
analyticsSdkManager.startSpan(name, parentTraceId)
|
||||||
} else {
|
} else {
|
||||||
analyticsSdkSpanFactory.bridge(parentTraceId)
|
analyticsSdkManager.bridge(parentTraceId)
|
||||||
}.apply { enter() }
|
}.apply { enter() }
|
||||||
} else {
|
} else {
|
||||||
NoopAnalyticsSdkSpan
|
NoopAnalyticsSdkSpan
|
||||||
|
|
|
||||||
|
|
@ -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.PollEnd
|
||||||
import im.vector.app.features.analytics.plan.SuperProperties
|
import im.vector.app.features.analytics.plan.SuperProperties
|
||||||
import im.vector.app.features.analytics.plan.UserProperties
|
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.api.observer.SessionObserver
|
||||||
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
|
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.AnalyticsStore
|
||||||
import io.element.android.services.analytics.impl.store.FakeAnalyticsStore
|
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.api.AnalyticsProvider
|
||||||
import io.element.android.services.analyticsproviders.test.FakeAnalyticsProvider
|
import io.element.android.services.analyticsproviders.test.FakeAnalyticsProvider
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
|
@ -33,6 +33,7 @@ import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -127,17 +128,20 @@ class DefaultAnalyticsServiceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 store = FakeAnalyticsStore()
|
||||||
val sut = createDefaultAnalyticsService(
|
val sut = createDefaultAnalyticsService(
|
||||||
coroutineScope = backgroundScope,
|
coroutineScope = backgroundScope,
|
||||||
analyticsStore = store,
|
analyticsStore = store,
|
||||||
|
sdkAnalyticsManager = FakeAnalyticsSdkManager(sdkAnalyticsEnabledLambda),
|
||||||
)
|
)
|
||||||
assertThat(store.userConsentFlow.first()).isFalse()
|
assertThat(store.userConsentFlow.first()).isFalse()
|
||||||
assertThat(sut.userConsentFlow.first()).isFalse()
|
assertThat(sut.userConsentFlow.first()).isFalse()
|
||||||
sut.setUserConsent(true)
|
sut.setUserConsent(true)
|
||||||
assertThat(store.userConsentFlow.first()).isTrue()
|
assertThat(store.userConsentFlow.first()).isTrue()
|
||||||
assertThat(sut.userConsentFlow.first()).isTrue()
|
assertThat(sut.userConsentFlow.first()).isTrue()
|
||||||
|
sdkAnalyticsEnabledLambda.assertions().isCalledOnce().with(value(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -171,15 +175,18 @@ class DefaultAnalyticsServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun `when the last session is deleted, the store is reset`() = runTest {
|
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(
|
val store = FakeAnalyticsStore(
|
||||||
resetLambda = resetLambda,
|
resetLambda = resetLambda,
|
||||||
)
|
)
|
||||||
val sut = createDefaultAnalyticsService(
|
val sut = createDefaultAnalyticsService(
|
||||||
coroutineScope = backgroundScope,
|
coroutineScope = backgroundScope,
|
||||||
analyticsStore = store,
|
analyticsStore = store,
|
||||||
|
sdkAnalyticsManager = FakeAnalyticsSdkManager(sdkAnalyticsEnabledLambda),
|
||||||
)
|
)
|
||||||
sut.onSessionDeleted("userId", true)
|
sut.onSessionDeleted("userId", true)
|
||||||
resetLambda.assertions().isCalledOnce()
|
resetLambda.assertions().isCalledOnce()
|
||||||
|
sdkAnalyticsEnabledLambda.assertions().isCalledOnce().with(value(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -235,7 +242,6 @@ class DefaultAnalyticsServiceTest {
|
||||||
fun `when consent is provided, updateUserProperties is sent to the provider`() = runTest {
|
fun `when consent is provided, updateUserProperties is sent to the provider`() = runTest {
|
||||||
val updateUserPropertiesLambda = lambdaRecorder<UserProperties, Unit> { _ -> }
|
val updateUserPropertiesLambda = lambdaRecorder<UserProperties, Unit> { _ -> }
|
||||||
val sut = createDefaultAnalyticsService(
|
val sut = createDefaultAnalyticsService(
|
||||||
coroutineScope = backgroundScope,
|
|
||||||
analyticsProviders = setOf(
|
analyticsProviders = setOf(
|
||||||
FakeAnalyticsProvider(
|
FakeAnalyticsProvider(
|
||||||
initLambda = { },
|
initLambda = { },
|
||||||
|
|
@ -252,7 +258,6 @@ class DefaultAnalyticsServiceTest {
|
||||||
fun `when super properties are updated, updateSuperProperties is sent to the provider`() = runTest {
|
fun `when super properties are updated, updateSuperProperties is sent to the provider`() = runTest {
|
||||||
val updateSuperPropertiesLambda = lambdaRecorder<SuperProperties, Unit> { _ -> }
|
val updateSuperPropertiesLambda = lambdaRecorder<SuperProperties, Unit> { _ -> }
|
||||||
val sut = createDefaultAnalyticsService(
|
val sut = createDefaultAnalyticsService(
|
||||||
coroutineScope = backgroundScope,
|
|
||||||
analyticsProviders = setOf(
|
analyticsProviders = setOf(
|
||||||
FakeAnalyticsProvider(
|
FakeAnalyticsProvider(
|
||||||
initLambda = { },
|
initLambda = { },
|
||||||
|
|
@ -265,8 +270,15 @@ class DefaultAnalyticsServiceTest {
|
||||||
updateSuperPropertiesLambda.assertions().isCalledOnce().with(value(aSuperProperty))
|
updateSuperPropertiesLambda.assertions().isCalledOnce().with(value(aSuperProperty))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createDefaultAnalyticsService(
|
@Test
|
||||||
coroutineScope: CoroutineScope,
|
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(
|
analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider> = setOf(
|
||||||
FakeAnalyticsProvider(
|
FakeAnalyticsProvider(
|
||||||
stopLambda = { },
|
stopLambda = { },
|
||||||
|
|
@ -274,12 +286,13 @@ class DefaultAnalyticsServiceTest {
|
||||||
),
|
),
|
||||||
analyticsStore: AnalyticsStore = FakeAnalyticsStore(),
|
analyticsStore: AnalyticsStore = FakeAnalyticsStore(),
|
||||||
sessionObserver: SessionObserver = NoOpSessionObserver(),
|
sessionObserver: SessionObserver = NoOpSessionObserver(),
|
||||||
|
sdkAnalyticsManager: FakeAnalyticsSdkManager = FakeAnalyticsSdkManager(enableSdkAnalyticsLambda = {}),
|
||||||
) = DefaultAnalyticsService(
|
) = DefaultAnalyticsService(
|
||||||
analyticsProviders = analyticsProviders,
|
analyticsProviders = analyticsProviders,
|
||||||
analyticsStore = analyticsStore,
|
analyticsStore = analyticsStore,
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
sessionObserver = sessionObserver,
|
sessionObserver = sessionObserver,
|
||||||
analyticsSdkSpanFactory = FakeAnalyticsSdkSpanFactory(),
|
analyticsSdkManager = sdkAnalyticsManager,
|
||||||
).also {
|
).also {
|
||||||
// Wait for the service to be ready
|
// Wait for the service to be ready
|
||||||
delay(1)
|
delay(1)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue