Allow bridging Sentry spans to the SDK ones.

Add distributed tracing for `Room.timelineWithConfiguration`, so we can inspect the associated Rust trace.
This commit is contained in:
Jorge Martín 2025-11-21 17:20:12 +01:00 committed by Jorge Martin Espinosa
parent ea36caf981
commit 11f41629c1
15 changed files with 195 additions and 10 deletions

View file

@ -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)
}
}

View file

@ -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'")
}
}

View file

@ -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.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService 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.analytics.api.recordTransaction
import io.element.android.services.analyticsproviders.api.recordChildTransaction import io.element.android.services.analyticsproviders.api.recordChildTransaction
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
@ -127,17 +128,19 @@ class RustRoomFactory(
val timeline = transaction.recordChildTransaction( val timeline = transaction.recordChildTransaction(
operation = "sdkRoom.timelineWithConfiguration", operation = "sdkRoom.timelineWithConfiguration",
description = "Get timeline from the SDK", description = "Get timeline from the SDK",
) { ) { timelineTransaction ->
sdkRoom.timelineWithConfiguration( analyticsService.inBridgeSdkSpan(parentTraceId = timelineTransaction.traceId()) {
TimelineConfiguration( sdkRoom.timelineWithConfiguration(
focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), TimelineConfiguration(
filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents),
internalIdPrefix = "live", filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All,
dateDividerMode = DateDividerMode.DAILY, internalIdPrefix = "live",
trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS, dateDividerMode = DateDividerMode.DAILY,
reportUtds = true, trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS,
reportUtds = true,
)
) )
) }
} }
GetRoomResult.Joined( GetRoomResult.Joined(

View file

@ -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()
}

View file

@ -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
}

View file

@ -72,6 +72,8 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker {
* Removes an ongoing [AnalyticsLongRunningTransaction] so it's no longer shared. * Removes an ongoing [AnalyticsLongRunningTransaction] so it's no longer shared.
*/ */
fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction?
fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan
} }
inline fun <T> AnalyticsService.recordTransaction( inline fun <T> AnalyticsService.recordTransaction(
@ -110,3 +112,12 @@ fun AnalyticsService.finishLongRunningTransaction(
it.finish() it.finish()
} }
} }
inline fun <T> AnalyticsService.inBridgeSdkSpan(parentTraceId: String?, block: (AnalyticsSdkSpan) -> T): T {
val span = enterSdkSpan(name = null, parentTraceId = parentTraceId)
return try {
block(span)
} finally {
span.exit()
}
}

View file

@ -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() {}
}

View file

@ -13,5 +13,6 @@ object NoopAnalyticsTransaction : AnalyticsTransaction {
override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction
override fun setData(key: String, value: Any) {} override fun setData(key: String, value: Any) {}
override fun isFinished(): Boolean = true override fun isFinished(): Boolean = true
override fun traceId(): String? = null
override fun finish() {} override fun finish() {}
} }

View file

@ -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.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.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.NoopAnalyticsTransaction import io.element.android.services.analytics.api.NoopAnalyticsTransaction
import io.element.android.services.analytics.impl.log.analyticsTag import io.element.android.services.analytics.impl.log.analyticsTag
import io.element.android.services.analytics.impl.store.AnalyticsStore import io.element.android.services.analytics.impl.store.AnalyticsStore
@ -43,6 +46,7 @@ class DefaultAnalyticsService(
@AppCoroutineScope @AppCoroutineScope
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver, private val sessionObserver: SessionObserver,
private val analyticsSdkSpanFactory: AnalyticsSdkSpanFactory,
) : AnalyticsService, SessionListener { ) : AnalyticsService, SessionListener {
private val pendingLongRunningTransactions = ConcurrentHashMap<AnalyticsLongRunningTransaction, AnalyticsTransaction>() private val pendingLongRunningTransactions = ConcurrentHashMap<AnalyticsLongRunningTransaction, AnalyticsTransaction>()
@ -171,4 +175,16 @@ class DefaultAnalyticsService(
override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? { override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? {
return pendingLongRunningTransactions.remove(longRunningTransaction) 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
}
}
} }

View file

@ -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.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
@ -278,6 +279,7 @@ class DefaultAnalyticsServiceTest {
analyticsStore = analyticsStore, analyticsStore = analyticsStore,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionObserver = sessionObserver, sessionObserver = sessionObserver,
analyticsSdkSpanFactory = FakeAnalyticsSdkSpanFactory(),
).also { ).also {
// Wait for the service to be ready // Wait for the service to be ready
delay(1) delay(1)

View file

@ -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.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction 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.AnalyticsService
import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan
import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analytics.api.NoopAnalyticsTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
@ -45,4 +47,6 @@ class NoopAnalyticsService : AnalyticsService {
): AnalyticsTransaction = NoopAnalyticsTransaction ): AnalyticsTransaction = NoopAnalyticsTransaction
override fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? = null override fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? = null
override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) = NoopAnalyticsTransaction override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) = NoopAnalyticsTransaction
override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan
} }

View file

@ -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
}

View file

@ -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.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction 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.AnalyticsService
import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan
import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analytics.api.NoopAnalyticsTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
@ -86,4 +88,6 @@ class FakeAnalyticsService(
override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? { override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? {
return longRunningTransactions.remove(longRunningTransaction) return longRunningTransactions.remove(longRunningTransaction)
} }
override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan
} }

View file

@ -11,6 +11,7 @@ interface AnalyticsTransaction {
fun startChild(operation: String, description: String? = null): AnalyticsTransaction fun startChild(operation: String, description: String? = null): AnalyticsTransaction
fun setData(key: String, value: Any) fun setData(key: String, value: Any)
fun isFinished(): Boolean fun isFinished(): Boolean
fun traceId(): String?
fun finish() fun finish()
} }

View file

@ -21,6 +21,7 @@ class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTra
inner.startChild(operation, description) inner.startChild(operation, description)
) )
override fun setData(key: String, value: Any) = inner.setData(key, value) 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 isFinished(): Boolean = inner.isFinished
override fun finish() { override fun finish() {
val name = if (inner is ITransaction) inner.name else inner.operation val name = if (inner is ITransaction) inner.name else inner.operation