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/DefaultAnalyticsSdkFactory.kt new file mode 100644 index 0000000000..b5d42a25e6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultAnalyticsSdkFactory.kt @@ -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.libraries.matrix.impl.analytics + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.services.analytics.api.AnalyticsSdkSpan +import io.element.android.services.analytics.api.AnalyticsSdkSpanFactory + +@ContributesBinding(AppScope::class) +class DefaultAnalyticsSdkFactory : AnalyticsSdkSpanFactory { + override fun create(name: String, parentTraceId: String?): AnalyticsSdkSpan { + return RustAnalyticsSdkSpan(name = name, parentTraceId = parentTraceId) + } + + override fun bridge(parentTraceId: String?): AnalyticsSdkSpan { + // A bridge span has no name + return RustAnalyticsSdkSpan(name = null, parentTraceId = parentTraceId) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkSpan.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkSpan.kt new file mode 100644 index 0000000000..9ed8763ec4 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkSpan.kt @@ -0,0 +1,51 @@ +/* + * 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.impl.analytics + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.services.analytics.api.AnalyticsSdkSpan +import kotlinx.coroutines.DelicateCoroutinesApi +import org.matrix.rustcomponents.sdk.LogLevel +import org.matrix.rustcomponents.sdk.Span +import timber.log.Timber + +class RustAnalyticsSdkSpan( + name: String? = null, + private val parentTraceId: String?, +) : AnalyticsSdkSpan { + private val inner = if (name != null) { + Span( + target = "elementx", + name = name, + file = "-", + line = null, + level = LogLevel.WARN, + bridgeTraceId = parentTraceId, + ) + } else { + Span.newBridgeSpan( + target = "elementx", + parentTraceId = parentTraceId, + ) + } + + override fun enter() { + if (Span.current().isNone()) { + inner.enter() + } else { + Timber.w("Not entering span sentry.trace='$parentTraceId' because another span is already active") + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun exit() { + inner.exit() + runCatchingExceptions { inner.destroy() } + Timber.d("Exited span sentry.trace='$parentTraceId'") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt index f184356f15..cbc39b1c61 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapp import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.inBridgeSdkSpan import io.element.android.services.analytics.api.recordTransaction import io.element.android.services.analyticsproviders.api.recordChildTransaction import io.element.android.services.toolbox.api.systemclock.SystemClock @@ -127,17 +128,19 @@ class RustRoomFactory( val timeline = transaction.recordChildTransaction( operation = "sdkRoom.timelineWithConfiguration", description = "Get timeline from the SDK", - ) { - sdkRoom.timelineWithConfiguration( - TimelineConfiguration( - focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), - filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, - internalIdPrefix = "live", - dateDividerMode = DateDividerMode.DAILY, - trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS, - reportUtds = true, + ) { timelineTransaction -> + analyticsService.inBridgeSdkSpan(parentTraceId = timelineTransaction.traceId()) { + sdkRoom.timelineWithConfiguration( + TimelineConfiguration( + focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), + filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, + internalIdPrefix = "live", + dateDividerMode = DateDividerMode.DAILY, + trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS, + reportUtds = true, + ) ) - ) + } } GetRoomResult.Joined( diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpan.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpan.kt new file mode 100644 index 0000000000..92f79da7f9 --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpan.kt @@ -0,0 +1,19 @@ +/* + * 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 + +/** + * Represents an analytics span in the Rust SDK. + */ +interface AnalyticsSdkSpan { + /** Enters the span and starts collecting metrics. */ + fun enter() + + /** Exit the span and stop collecting the metrics. A request should be sent shortly after. */ + fun exit() +} 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 new file mode 100644 index 0000000000..49558f37b0 --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpanFactory.kt @@ -0,0 +1,16 @@ +/* + * 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/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt index 08562b01ce..9aeaf293cc 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt @@ -72,6 +72,8 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker { * Removes an ongoing [AnalyticsLongRunningTransaction] so it's no longer shared. */ fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? + + fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan } inline fun AnalyticsService.recordTransaction( @@ -110,3 +112,12 @@ fun AnalyticsService.finishLongRunningTransaction( it.finish() } } + +inline fun AnalyticsService.inBridgeSdkSpan(parentTraceId: String?, block: (AnalyticsSdkSpan) -> T): T { + val span = enterSdkSpan(name = null, parentTraceId = parentTraceId) + return try { + block(span) + } finally { + span.exit() + } +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsSdkSpan.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsSdkSpan.kt new file mode 100644 index 0000000000..d4db9359e2 --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsSdkSpan.kt @@ -0,0 +1,13 @@ +/* + * 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 + +object NoopAnalyticsSdkSpan : AnalyticsSdkSpan { + override fun enter() {} + override fun exit() {} +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt index 2b18f8408c..024a7ac05e 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt @@ -13,5 +13,6 @@ object NoopAnalyticsTransaction : AnalyticsTransaction { override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction override fun setData(key: String, value: Any) {} override fun isFinished(): Boolean = true + override fun traceId(): String? = null override fun finish() {} } 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 f1a5b444de..3dd9abf5d5 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,7 +20,10 @@ 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.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 import io.element.android.services.analytics.impl.log.analyticsTag import io.element.android.services.analytics.impl.store.AnalyticsStore @@ -43,6 +46,7 @@ class DefaultAnalyticsService( @AppCoroutineScope private val coroutineScope: CoroutineScope, private val sessionObserver: SessionObserver, + private val analyticsSdkSpanFactory: AnalyticsSdkSpanFactory, ) : AnalyticsService, SessionListener { private val pendingLongRunningTransactions = ConcurrentHashMap() @@ -171,4 +175,16 @@ class DefaultAnalyticsService( override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? { return pendingLongRunningTransactions.remove(longRunningTransaction) } + + override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan { + return if (userConsent.get()) { + if (name != null) { + analyticsSdkSpanFactory.create(name, parentTraceId) + } else { + analyticsSdkSpanFactory.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 86a0c08d25..d80fb55d3f 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 @@ -21,6 +21,7 @@ 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 @@ -278,6 +279,7 @@ class DefaultAnalyticsServiceTest { analyticsStore = analyticsStore, coroutineScope = coroutineScope, sessionObserver = sessionObserver, + analyticsSdkSpanFactory = FakeAnalyticsSdkSpanFactory(), ).also { // Wait for the service to be ready delay(1) diff --git a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt index 591651ed6c..004a472de3 100644 --- a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt +++ b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt @@ -16,7 +16,9 @@ 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.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsSdkSpan import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsTransaction @@ -45,4 +47,6 @@ class NoopAnalyticsService : AnalyticsService { ): AnalyticsTransaction = NoopAnalyticsTransaction override fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? = null override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) = NoopAnalyticsTransaction + + override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan } 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 new file mode 100644 index 0000000000..e01f893d6d --- /dev/null +++ b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsSdkSpanFactory.kt @@ -0,0 +1,18 @@ +/* + * 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 +} diff --git a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt index 274d06f6a5..b9b33744e8 100644 --- a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt +++ b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt @@ -13,7 +13,9 @@ 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.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsSdkSpan import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsTransaction @@ -86,4 +88,6 @@ class FakeAnalyticsService( override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? { return longRunningTransactions.remove(longRunningTransaction) } + + override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan } diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt index 8297055f96..ea63a7f167 100644 --- a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt @@ -11,6 +11,7 @@ interface AnalyticsTransaction { fun startChild(operation: String, description: String? = null): AnalyticsTransaction fun setData(key: String, value: Any) fun isFinished(): Boolean + fun traceId(): String? fun finish() } diff --git a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt index 90bbd77e00..75872b7d44 100644 --- a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt @@ -21,6 +21,7 @@ class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTra inner.startChild(operation, description) ) override fun setData(key: String, value: Any) = inner.setData(key, value) + override fun traceId(): String? = inner.toSentryTrace().value override fun isFinished(): Boolean = inner.isFinished override fun finish() { val name = if (inner is ITransaction) inner.name else inner.operation