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:
parent
ea36caf981
commit
11f41629c1
15 changed files with 195 additions and 10 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {}
|
||||||
|
}
|
||||||
|
|
@ -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() {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue