forked from dsutanto/bChot-android
First Commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.services.analyticsproviders.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.matrix.analytics.events)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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.api
|
||||
|
||||
import io.element.android.services.analyticsproviders.api.trackers.AnalyticsTracker
|
||||
import io.element.android.services.analyticsproviders.api.trackers.ErrorTracker
|
||||
|
||||
interface AnalyticsProvider : AnalyticsTracker, ErrorTracker {
|
||||
/**
|
||||
* User friendly name.
|
||||
*/
|
||||
val name: String
|
||||
|
||||
fun init()
|
||||
|
||||
fun stop()
|
||||
|
||||
fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction?
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.api
|
||||
|
||||
interface AnalyticsTransaction {
|
||||
fun startChild(operation: String, description: String? = null): AnalyticsTransaction
|
||||
fun setData(key: String, value: Any)
|
||||
fun isFinished(): Boolean
|
||||
fun finish()
|
||||
}
|
||||
|
||||
inline fun <T> AnalyticsTransaction.recordChildTransaction(operation: String, description: String? = null, block: (AnalyticsTransaction) -> T): T {
|
||||
val child = startChild(operation, description)
|
||||
try {
|
||||
val result = block(child)
|
||||
return result
|
||||
} finally {
|
||||
child.finish()
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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.api.trackers
|
||||
|
||||
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
|
||||
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import im.vector.app.features.analytics.plan.SuperProperties
|
||||
import im.vector.app.features.analytics.plan.UserProperties
|
||||
|
||||
interface AnalyticsTracker {
|
||||
/**
|
||||
* Capture an Event.
|
||||
*/
|
||||
fun capture(event: VectorAnalyticsEvent)
|
||||
|
||||
/**
|
||||
* Track a displayed screen.
|
||||
*/
|
||||
fun screen(screen: VectorAnalyticsScreen)
|
||||
|
||||
/**
|
||||
* Update user specific properties.
|
||||
*/
|
||||
fun updateUserProperties(userProperties: UserProperties)
|
||||
|
||||
/**
|
||||
* Update the super properties.
|
||||
* Super properties are added to any tracked event automatically.
|
||||
*/
|
||||
fun updateSuperProperties(updatedProperties: SuperProperties)
|
||||
}
|
||||
|
||||
fun AnalyticsTracker.captureInteraction(name: Interaction.Name, type: Interaction.InteractionType? = null) {
|
||||
capture(Interaction(interactionType = type, name = name))
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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.api.trackers
|
||||
|
||||
interface ErrorTracker {
|
||||
fun trackError(throwable: Throwable)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import config.BuildTimeConfig
|
||||
import extension.buildConfigFieldStr
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.services.analyticsproviders.posthog"
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
buildConfigFieldStr(
|
||||
name = "POSTHOG_HOST",
|
||||
value = BuildTimeConfig.SERVICES_POSTHOG_HOST.takeIf { isEnterpriseBuild } ?: ""
|
||||
)
|
||||
buildConfigFieldStr(
|
||||
name = "POSTHOG_APIKEY",
|
||||
value = BuildTimeConfig.SERVICES_POSTHOG_APIKEY.takeIf { isEnterpriseBuild } ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
implementation(libs.posthog) {
|
||||
exclude("com.android.support", "support-annotations")
|
||||
}
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.services.analyticsproviders.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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.posthog
|
||||
|
||||
import android.content.Context
|
||||
import com.posthog.PostHogInterface
|
||||
import com.posthog.android.PostHogAndroid
|
||||
import com.posthog.android.PostHogAndroidConfig
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
|
||||
@Inject
|
||||
class PostHogFactory(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val posthogEndpointConfigProvider: PosthogEndpointConfigProvider,
|
||||
) {
|
||||
fun createPosthog(): PostHogInterface? {
|
||||
val endpoint = posthogEndpointConfigProvider.provide() ?: return null
|
||||
return PostHogAndroid.with(
|
||||
context,
|
||||
PostHogAndroidConfig(
|
||||
apiKey = endpoint.apiKey,
|
||||
host = endpoint.host,
|
||||
captureApplicationLifecycleEvents = false,
|
||||
captureDeepLinks = false,
|
||||
captureScreenViews = false,
|
||||
).also {
|
||||
it.debug = buildMeta.isDebuggable
|
||||
it.sendFeatureFlagEvent = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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.posthog
|
||||
|
||||
import com.posthog.PostHogInterface
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.services.analyticsproviders.api.AnalyticsProvider
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
|
||||
import io.element.android.services.analyticsproviders.posthog.log.analyticsTag
|
||||
import timber.log.Timber
|
||||
|
||||
// private val REUSE_EXISTING_ID: String? = null
|
||||
// private val IGNORED_OPTIONS: Options? = null
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@Inject
|
||||
class PosthogAnalyticsProvider(
|
||||
private val postHogFactory: PostHogFactory,
|
||||
) : AnalyticsProvider {
|
||||
override val name = "Posthog"
|
||||
|
||||
private var posthog: PostHogInterface? = null
|
||||
private var analyticsId: String? = null
|
||||
|
||||
private var pendingUserProperties: MutableMap<String, Any>? = null
|
||||
|
||||
private var superProperties: SuperProperties? = null
|
||||
|
||||
private val userPropertiesLock = Any()
|
||||
|
||||
override fun init() {
|
||||
posthog = postHogFactory.createPosthog()
|
||||
posthog?.optIn()
|
||||
// Timber.e("PostHog distinctId: ${posthog?.distinctId()}")
|
||||
identifyPostHog()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
// When opting out, ensure that the queue is flushed first, or it will be flushed later (after user has revoked consent)
|
||||
posthog?.flush()
|
||||
posthog?.optOut()
|
||||
posthog?.close()
|
||||
posthog = null
|
||||
analyticsId = null
|
||||
}
|
||||
|
||||
override fun capture(event: VectorAnalyticsEvent) {
|
||||
synchronized(userPropertiesLock) {
|
||||
posthog?.capture(
|
||||
event = event.getName(),
|
||||
properties = event.getProperties()?.keepOnlyNonNullValues().withSuperProperties(),
|
||||
userProperties = pendingUserProperties,
|
||||
)
|
||||
pendingUserProperties = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun screen(screen: VectorAnalyticsScreen) {
|
||||
posthog?.screen(
|
||||
screenTitle = screen.getName(),
|
||||
properties = screen.getProperties().withSuperProperties(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateUserProperties(userProperties: UserProperties) {
|
||||
synchronized(userPropertiesLock) {
|
||||
// The pending properties will be sent with the following capture call
|
||||
if (pendingUserProperties == null) {
|
||||
pendingUserProperties = HashMap()
|
||||
}
|
||||
userProperties.getProperties()?.let {
|
||||
pendingUserProperties?.putAll(it)
|
||||
}
|
||||
// We are not currently using `identify` in EAX, if it was the case
|
||||
// we could have called identify to update the user properties.
|
||||
// For now, we have to store them, and they will be updated when the next call
|
||||
// to capture will happen.
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateSuperProperties(updatedProperties: SuperProperties) {
|
||||
this.superProperties = SuperProperties(
|
||||
cryptoSDK = updatedProperties.cryptoSDK ?: this.superProperties?.cryptoSDK,
|
||||
appPlatform = updatedProperties.appPlatform ?: this.superProperties?.appPlatform,
|
||||
cryptoSDKVersion = updatedProperties.cryptoSDKVersion ?: superProperties?.cryptoSDKVersion
|
||||
)
|
||||
}
|
||||
|
||||
override fun trackError(throwable: Throwable) {
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
private fun identifyPostHog() {
|
||||
val id = analyticsId ?: return
|
||||
if (id.isEmpty()) {
|
||||
Timber.tag(analyticsTag.value).d("reset")
|
||||
posthog?.reset()
|
||||
} else {
|
||||
Timber.tag(analyticsTag.value).d("identify")
|
||||
// posthog?.identify(id, lateInitUserPropertiesFactory.createUserProperties()?.getProperties()?.toPostHogUserProperties(), IGNORED_OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Any>?.withSuperProperties(): Map<String, Any>? {
|
||||
val withSuperProperties = this.orEmpty().toMutableMap()
|
||||
val superProperties = this@PosthogAnalyticsProvider.superProperties?.getProperties()
|
||||
superProperties?.forEach {
|
||||
if (!withSuperProperties.containsKey(it.key)) {
|
||||
withSuperProperties[it.key] = it.value
|
||||
}
|
||||
}
|
||||
return withSuperProperties.takeIf { it.isEmpty().not() }
|
||||
}
|
||||
|
||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.keepOnlyNonNullValues(): Map<String, Any> {
|
||||
val result = mutableMapOf<String, Any>()
|
||||
for (entry in this) {
|
||||
val value = entry.value
|
||||
if (value != null) {
|
||||
result[entry.key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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.posthog
|
||||
|
||||
data class PosthogEndpointConfig(
|
||||
val host: String,
|
||||
val apiKey: String,
|
||||
) {
|
||||
val isValid = host.isNotBlank() && apiKey.isNotBlank()
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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.posthog
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.core.extensions.isElement
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
|
||||
@Inject
|
||||
class PosthogEndpointConfigProvider(
|
||||
private val buildMeta: BuildMeta,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
) {
|
||||
fun provide(): PosthogEndpointConfig? {
|
||||
return if (enterpriseService.isEnterpriseBuild) {
|
||||
PosthogEndpointConfig(
|
||||
host = BuildConfig.POSTHOG_HOST,
|
||||
apiKey = BuildConfig.POSTHOG_APIKEY,
|
||||
).takeIf {
|
||||
// Note that if the config is invalid, this module will not be included in the build.
|
||||
// So the configuration should be always valid.
|
||||
it.isValid
|
||||
}
|
||||
} else if (buildMeta.isElement()) {
|
||||
when (buildMeta.buildType) {
|
||||
BuildType.RELEASE -> PosthogEndpointConfig(
|
||||
host = "https://posthog.element.io",
|
||||
apiKey = "phc_Jzsm6DTm6V2705zeU5dcNvQDlonOR68XvX2sh1sEOHO",
|
||||
)
|
||||
BuildType.NIGHTLY,
|
||||
BuildType.DEBUG -> PosthogEndpointConfig(
|
||||
host = "https://posthog.element.dev",
|
||||
apiKey = "phc_VtA1L35nw3aeAtHIx1ayrGdzGkss7k1xINeXcoIQzXN",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-2025 New Vector 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.posthog.extensions
|
||||
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
|
||||
fun Interaction.Name.toAnalyticsInteraction(interactionType: Interaction.InteractionType = Interaction.InteractionType.Touch) =
|
||||
Interaction(
|
||||
name = this,
|
||||
interactionType = interactionType
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2021-2025 New Vector 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.posthog.log
|
||||
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
|
||||
internal val analyticsTag = LoggerTag("Posthog")
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector 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.posthog
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.posthog.PostHogInterface
|
||||
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import im.vector.app.features.analytics.plan.SuperProperties
|
||||
import im.vector.app.features.analytics.plan.UserProperties
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PosthogAnalyticsProviderTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `Posthog - Test user properties`() = runTest {
|
||||
val mockPosthog = mockk<PostHogInterface>().also {
|
||||
every { it.optIn() } just runs
|
||||
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
|
||||
}
|
||||
val mockPosthogFactory = mockk<PostHogFactory>()
|
||||
every { mockPosthogFactory.createPosthog() } returns mockPosthog
|
||||
|
||||
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
|
||||
analyticsProvider.init()
|
||||
|
||||
val testUserProperties = UserProperties(
|
||||
verificationState = UserProperties.VerificationState.Verified,
|
||||
recoveryState = UserProperties.RecoveryState.Incomplete,
|
||||
)
|
||||
analyticsProvider.updateUserProperties(testUserProperties)
|
||||
|
||||
// report mock event
|
||||
val mockEvent = mockk<VectorAnalyticsEvent>().also {
|
||||
every {
|
||||
it.getProperties()
|
||||
} returns emptyMap()
|
||||
every { it.getName() } returns "MockEventName"
|
||||
}
|
||||
analyticsProvider.capture(mockEvent)
|
||||
val userPropertiesSlot = slot<Map<String, Any>>()
|
||||
|
||||
verify { mockPosthog.capture(event = "MockEventName", any(), any(), userProperties = capture(userPropertiesSlot)) }
|
||||
|
||||
assertThat(userPropertiesSlot.captured).isNotNull()
|
||||
assertThat(userPropertiesSlot.captured["verificationState"] as String).isEqualTo(testUserProperties.verificationState?.name)
|
||||
assertThat(userPropertiesSlot.captured["recoveryState"] as String).isEqualTo(testUserProperties.recoveryState?.name)
|
||||
|
||||
// Should only be reported once when the next event is sent
|
||||
// Try another capture to check
|
||||
analyticsProvider.capture(mockEvent)
|
||||
verify { mockPosthog.capture(any(), any(), any(), userProperties = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Posthog - Test accumulate user properties until next capture call`() = runTest {
|
||||
val mockPosthog = mockk<PostHogInterface>().also {
|
||||
every { it.optIn() } just runs
|
||||
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
|
||||
}
|
||||
val mockPosthogFactory = mockk<PostHogFactory>()
|
||||
every { mockPosthogFactory.createPosthog() } returns mockPosthog
|
||||
|
||||
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
|
||||
analyticsProvider.init()
|
||||
|
||||
val testUserProperties = UserProperties(
|
||||
verificationState = UserProperties.VerificationState.NotVerified,
|
||||
)
|
||||
analyticsProvider.updateUserProperties(testUserProperties)
|
||||
|
||||
// Update again
|
||||
val testUserPropertiesUpdate = UserProperties(
|
||||
verificationState = UserProperties.VerificationState.Verified,
|
||||
)
|
||||
analyticsProvider.updateUserProperties(testUserPropertiesUpdate)
|
||||
|
||||
// report mock event
|
||||
val mockEvent = mockk<VectorAnalyticsEvent>().also {
|
||||
every {
|
||||
it.getProperties()
|
||||
} returns emptyMap()
|
||||
every { it.getName() } returns "MockEventName"
|
||||
}
|
||||
analyticsProvider.capture(mockEvent)
|
||||
val userPropertiesSlot = slot<Map<String, Any>>()
|
||||
|
||||
verify { mockPosthog.capture(event = "MockEventName", any(), any(), userProperties = capture(userPropertiesSlot)) }
|
||||
|
||||
assertThat(userPropertiesSlot.captured).isNotNull()
|
||||
assertThat(userPropertiesSlot.captured["verificationState"] as String).isEqualTo(testUserPropertiesUpdate.verificationState?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Posthog - Test super properties added to all captured events`() = runTest {
|
||||
val mockPosthog = mockk<PostHogInterface>().also {
|
||||
every { it.optIn() } just runs
|
||||
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
|
||||
every { it.screen(any(), any()) } just runs
|
||||
}
|
||||
val mockPosthogFactory = mockk<PostHogFactory>()
|
||||
every { mockPosthogFactory.createPosthog() } returns mockPosthog
|
||||
|
||||
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
|
||||
analyticsProvider.init()
|
||||
|
||||
val testSuperProperties = SuperProperties(
|
||||
appPlatform = SuperProperties.AppPlatform.EXA,
|
||||
)
|
||||
analyticsProvider.updateSuperProperties(testSuperProperties)
|
||||
|
||||
// Test with events having different sort of properties
|
||||
listOf(
|
||||
mapOf("foo" to "bar"),
|
||||
mapOf("a" to "aValue1", "b" to "aValue2"),
|
||||
null
|
||||
).forEach { eventProperties ->
|
||||
// report an event with properties
|
||||
val mockEvent = mockk<VectorAnalyticsEvent>().also {
|
||||
every {
|
||||
it.getProperties()
|
||||
} returns eventProperties
|
||||
every { it.getName() } returns "MockEventName"
|
||||
}
|
||||
|
||||
analyticsProvider.capture(mockEvent)
|
||||
|
||||
val expectedProperties = eventProperties.orEmpty() + testSuperProperties.getProperties().orEmpty()
|
||||
verify { mockPosthog.capture(event = "MockEventName", any(), properties = expectedProperties, any()) }
|
||||
}
|
||||
|
||||
// / Test it is also added to screens
|
||||
val screenEvent = MobileScreen(null, MobileScreen.ScreenName.Home)
|
||||
analyticsProvider.screen(screenEvent)
|
||||
verify { mockPosthog.screen(MobileScreen.ScreenName.Home.rawValue, testSuperProperties.getProperties()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Posthog - Test super properties can be updated`() = runTest {
|
||||
val mockPosthog = mockk<PostHogInterface>().also {
|
||||
every { it.optIn() } just runs
|
||||
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
|
||||
}
|
||||
val mockPosthogFactory = mockk<PostHogFactory>()
|
||||
every { mockPosthogFactory.createPosthog() } returns mockPosthog
|
||||
|
||||
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
|
||||
analyticsProvider.init()
|
||||
|
||||
// Test with events having different sort of aggregation
|
||||
// left is the updated properties, right is the expected aggregated state
|
||||
mapOf(
|
||||
SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA) to SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA),
|
||||
SuperProperties(cryptoSDKVersion = "0.0") to SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA, cryptoSDKVersion = "0.0"),
|
||||
SuperProperties(cryptoSDKVersion = "1.0") to SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA, cryptoSDKVersion = "1.0"),
|
||||
SuperProperties(cryptoSDK = SuperProperties.CryptoSDK.Rust) to SuperProperties(
|
||||
appPlatform = SuperProperties.AppPlatform.EXA,
|
||||
cryptoSDKVersion = "1.0",
|
||||
cryptoSDK = SuperProperties.CryptoSDK.Rust
|
||||
),
|
||||
).entries.forEach { (updated, expected) ->
|
||||
// report an event with properties
|
||||
val mockEvent = mockk<VectorAnalyticsEvent>().also {
|
||||
every {
|
||||
it.getProperties()
|
||||
} returns null
|
||||
every { it.getName() } returns "MockEventName"
|
||||
}
|
||||
|
||||
analyticsProvider.updateSuperProperties(updated)
|
||||
analyticsProvider.capture(mockEvent)
|
||||
|
||||
verify { mockPosthog.capture(event = "MockEventName", any(), properties = expected.getProperties(), any()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Posthog - Test super properties do not override property with same name on the event`() = runTest {
|
||||
val mockPosthog = mockk<PostHogInterface>().also {
|
||||
every { it.optIn() } just runs
|
||||
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
|
||||
}
|
||||
val mockPosthogFactory = mockk<PostHogFactory>()
|
||||
every { mockPosthogFactory.createPosthog() } returns mockPosthog
|
||||
|
||||
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
|
||||
analyticsProvider.init()
|
||||
|
||||
// report an event with properties
|
||||
val mockEvent = mockk<VectorAnalyticsEvent>().also {
|
||||
every {
|
||||
it.getProperties()
|
||||
} returns mapOf("appPlatform" to SuperProperties.AppPlatform.Other)
|
||||
every { it.getName() } returns "MockEventName"
|
||||
}
|
||||
|
||||
analyticsProvider.updateSuperProperties(SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA))
|
||||
analyticsProvider.capture(mockEvent)
|
||||
|
||||
verify { mockPosthog.capture(event = "MockEventName", any(), properties = mapOf("appPlatform" to SuperProperties.AppPlatform.Other), any()) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import config.BuildTimeConfig
|
||||
import extension.buildConfigFieldStr
|
||||
import extension.readLocalProperty
|
||||
import extension.setupDependencyInjection
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.services.analyticsproviders.sentry"
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
buildConfigFieldStr(
|
||||
name = "SENTRY_DSN",
|
||||
value = if (isEnterpriseBuild) {
|
||||
BuildTimeConfig.SERVICES_SENTRY_DSN
|
||||
} else {
|
||||
System.getenv("ELEMENT_ANDROID_SENTRY_DSN")
|
||||
?: readLocalProperty("services.analyticsproviders.sentry.dsn")
|
||||
}
|
||||
?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
implementation(libs.sentry)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.services.analyticsproviders.api)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2025 Element Creations Ltd.
|
||||
~ Copyright 2023 New Vector Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<!-- Sentry auto-initialization disabled -->
|
||||
<meta-data
|
||||
android:name="io.sentry.auto-init"
|
||||
android:value="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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 android.content.Context
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
|
||||
import io.element.android.services.analyticsproviders.sentry.log.analyticsTag
|
||||
import io.sentry.Breadcrumb
|
||||
import io.sentry.Sentry
|
||||
import io.sentry.SentryOptions
|
||||
import io.sentry.android.core.SentryAndroid
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@Inject
|
||||
class SentryAnalyticsProvider(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : AnalyticsProvider {
|
||||
override val name = SentryConfig.NAME
|
||||
|
||||
override fun init() {
|
||||
Timber.tag(analyticsTag.value).d("Initializing Sentry")
|
||||
if (Sentry.isEnabled()) return
|
||||
|
||||
val dsn = SentryConfig.DSN.ifBlank {
|
||||
Timber.w("No Sentry DSN provided, Sentry will not be initialized")
|
||||
return
|
||||
}
|
||||
|
||||
SentryAndroid.init(context) { options ->
|
||||
options.dsn = dsn
|
||||
options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> event }
|
||||
options.tracesSampleRate = 1.0
|
||||
options.isEnableUserInteractionTracing = true
|
||||
options.environment = buildMeta.buildType.toSentryEnv()
|
||||
}
|
||||
Timber.tag(analyticsTag.value).d("Sentry was initialized correctly")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Timber.tag(analyticsTag.value).d("Stopping Sentry")
|
||||
Sentry.close()
|
||||
}
|
||||
|
||||
override fun capture(event: VectorAnalyticsEvent) {
|
||||
val breadcrumb = Breadcrumb(event.getName()).apply {
|
||||
category = "event"
|
||||
for ((key, value) in event.getProperties().orEmpty()) {
|
||||
setData(key, value.toString())
|
||||
}
|
||||
}
|
||||
Sentry.addBreadcrumb(breadcrumb)
|
||||
}
|
||||
|
||||
override fun screen(screen: VectorAnalyticsScreen) {
|
||||
val breadcrumb = Breadcrumb(screen.getName()).apply {
|
||||
category = "screen"
|
||||
for ((key, value) in screen.getProperties().orEmpty()) {
|
||||
setData(key, value.toString())
|
||||
}
|
||||
}
|
||||
Sentry.addBreadcrumb(breadcrumb)
|
||||
}
|
||||
|
||||
override fun updateUserProperties(userProperties: UserProperties) {
|
||||
}
|
||||
|
||||
override fun updateSuperProperties(updatedProperties: SuperProperties) {
|
||||
}
|
||||
|
||||
override fun trackError(throwable: Throwable) {
|
||||
Sentry.captureException(throwable)
|
||||
}
|
||||
|
||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? {
|
||||
return SentryAnalyticsTransaction(name, operation)
|
||||
}
|
||||
}
|
||||
|
||||
private fun BuildType.toSentryEnv() = when (this) {
|
||||
BuildType.RELEASE -> SentryConfig.ENV_RELEASE
|
||||
BuildType.NIGHTLY -> SentryConfig.ENV_NIGHTLY
|
||||
BuildType.DEBUG -> SentryConfig.ENV_DEBUG
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 io.element.android.services.analyticsproviders.api.AnalyticsTransaction
|
||||
import io.sentry.ISpan
|
||||
import io.sentry.ITransaction
|
||||
import io.sentry.Sentry
|
||||
import timber.log.Timber
|
||||
|
||||
class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction {
|
||||
constructor(name: String, operation: String?) : this(Sentry.startTransaction(name, operation.orEmpty()))
|
||||
private val inner = span
|
||||
|
||||
override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction(
|
||||
inner.startChild(operation, description)
|
||||
)
|
||||
override fun setData(key: String, value: Any) = inner.setData(key, value)
|
||||
override fun isFinished(): Boolean = inner.isFinished
|
||||
override fun finish() {
|
||||
val name = if (inner is ITransaction) inner.name else inner.operation
|
||||
Timber.d("Finishing transaction: $name")
|
||||
inner.finish()
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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
|
||||
|
||||
object SentryConfig {
|
||||
const val NAME = "Sentry"
|
||||
const val DSN = BuildConfig.SENTRY_DSN
|
||||
const val ENV_DEBUG = "DEBUG"
|
||||
const val ENV_NIGHTLY = "NIGHTLY"
|
||||
const val ENV_RELEASE = "RELEASE"
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector 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.log
|
||||
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
|
||||
internal val analyticsTag = LoggerTag("Sentry")
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.services.analyticsproviders.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.services.analyticsproviders.api)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector 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.test
|
||||
|
||||
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.services.analyticsproviders.api.AnalyticsProvider
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeAnalyticsProvider(
|
||||
override val name: String = "FakeAnalyticsProvider",
|
||||
private val initLambda: () -> Unit = { lambdaError() },
|
||||
private val stopLambda: () -> Unit = { lambdaError() },
|
||||
private val captureLambda: (VectorAnalyticsEvent) -> Unit = { lambdaError() },
|
||||
private val screenLambda: (VectorAnalyticsScreen) -> Unit = { lambdaError() },
|
||||
private val updateUserPropertiesLambda: (UserProperties) -> Unit = { lambdaError() },
|
||||
private val updateSuperPropertiesLambda: (SuperProperties) -> Unit = { lambdaError() },
|
||||
private val trackErrorLambda: (Throwable) -> Unit = { lambdaError() }
|
||||
) : AnalyticsProvider {
|
||||
override fun init() = initLambda()
|
||||
override fun stop() = stopLambda()
|
||||
override fun capture(event: VectorAnalyticsEvent) = captureLambda(event)
|
||||
override fun screen(screen: VectorAnalyticsScreen) = screenLambda(screen)
|
||||
override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties)
|
||||
override fun trackError(throwable: Throwable) = trackErrorLambda(throwable)
|
||||
override fun updateSuperProperties(updatedProperties: SuperProperties) = updateSuperPropertiesLambda(updatedProperties)
|
||||
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null
|
||||
}
|
||||
Reference in New Issue
Block a user