First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -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)
}
@@ -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?
}
@@ -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()
}
}
@@ -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))
}
@@ -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)
}
@@ -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
}
)
}
}
@@ -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
}
@@ -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()
}
@@ -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
}
}
}
@@ -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
)
@@ -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")
@@ -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>
@@ -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
}
@@ -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()
}
}
@@ -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"
}
@@ -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)
}
@@ -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
}