From 4c67ea93ef1da4700358841ec2b15524ebc68c3b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 20 May 2026 14:59:54 +0200 Subject: [PATCH] feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275) Introduces support for AndroidX's SQLiteDriver via a new SentrySQLiteDriver wrapper. SentrySQLiteDriver automatically creates spans for each SQL statement it executes, and its data scheme closely tracks that of SentrySupportSQLiteOpenHelper, which it's designed to replace. (Span duration is an important exception; see the SentrySQLiteStatement KDoc for more details.) A key motivation for Google's using SQLiteDriver with Room 2.7+ was Kotlin Multiplatform support. We've been careful to keep the SentrySQLiteDriver KMP-compatible as well, should we one day want to lift it into sentry-kotlin-multiplatform. --- Co-authored-by: Angus Holder <7407345+angusholder@users.noreply.github.com> --- CHANGELOG.md | 9 + sentry-android-sqlite/README.md | 21 ++ .../api/sentry-android-sqlite.api | 11 + .../android/sqlite/SQLiteSpanManager.kt | 37 +-- .../main/java/io/sentry/sqlite/DbMetadata.kt | 55 ++++ .../java/io/sentry/sqlite/SQLiteSpanHelper.kt | 33 ++ .../io/sentry/sqlite/SQLiteSpanRecorder.kt | 45 +++ .../sentry/sqlite/SentrySQLiteConnection.kt | 16 + .../io/sentry/sqlite/SentrySQLiteDriver.kt | 66 ++++ .../io/sentry/sqlite/SentrySQLiteStatement.kt | 81 +++++ .../java/io/sentry/sqlite/DbMetadataTest.kt | 87 ++++++ .../sentry/sqlite/SQLiteSpanRecorderTest.kt | 156 ++++++++++ .../sqlite/SentrySQLiteConnectionTest.kt | 63 ++++ .../sentry/sqlite/SentrySQLiteDriverTest.kt | 131 ++++++++ .../sqlite/SentrySQLiteStatementTest.kt | 292 ++++++++++++++++++ 15 files changed, 1076 insertions(+), 27 deletions(-) create mode 100644 sentry-android-sqlite/README.md create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanHelper.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c21a8cce7d7..c0562f682db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Features + +- Add `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting AndroidX's `SQLiteDriver` ([#5466](https://github.com/getsentry/sentry-java/pull/5466)) + - Automatically generates spans for all SQLite statements + - To use it, pass your `SQLiteDriver` to `SentrySQLiteDriver.create(...)` + - See https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/ for more details, including info about migrating from `SentrySupportSQLiteOpenHelper` + ## 8.43.0 ### Features diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md new file mode 100644 index 00000000000..c6bd8b68369 --- /dev/null +++ b/sentry-android-sqlite/README.md @@ -0,0 +1,21 @@ +# sentry-android-sqlite + +This module provides automatic SQLite query instrumentation for Android. + +Two instrumentation paths are supported, matching the two SQLite APIs offered by AndroidX: + +- **`androidx.sqlite.SQLiteDriver`** — used by Room 2.7+ via `Room.databaseBuilder(...).setDriver(...)` and by SQLDelight via its AndroidX SQLite driver. +- **`androidx.sqlite.db.SupportSQLiteOpenHelper`** — used by legacy Room via `Room.databaseBuilder(...).openHelperFactory(...)`, or applied automatically by the Sentry Android Gradle plugin. + +Please consult the [Sentry Docs](https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/) for usage and migration guidance, as well as how to avoid duplicate spans when using Room's `SupportSQLiteDriver` adapter. + +## Package layout + +This module is organized as two separate packages: + +- **`io.sentry.android.sqlite`**: Android-specific code. Classes here depend on `android.database.*` (e.g., `CrossProcessCursor`, `SQLException`) and/or on `androidx.sqlite.db.*`, the Android-only compatibility layer over the platform's SQLite. The `SentrySupportSQLiteOpenHelper` path and its span helper `SQLiteSpanManager` live here. +- **`io.sentry.sqlite`**: Code whose contract depends only on the multiplatform `androidx.sqlite.*` interfaces (e.g., `SQLiteDriver` and `SQLiteConnection`). `SentrySQLiteDriver` and its span helper `SQLiteSpanRecorder` live here. + +The split anticipates the possibility of future Kotlin Multiplatform support. The `androidx.sqlite.*` driver interfaces are defined in the library's `commonMain` source set and are reused by Room across Android, JVM, and native targets. Classes in `io.sentry.sqlite` are written against those portable interfaces and are intended to lift cleanly into a KMP `commonMain` source set if/when the `sentry` core gains multiplatform targets. Classes in `io.sentry.android.sqlite` are Android-only by construction and will stay where they are. + +Note that the module artifact itself (`sentry-android-sqlite`) is currently an Android-only AAR regardless of package layout. diff --git a/sentry-android-sqlite/api/sentry-android-sqlite.api b/sentry-android-sqlite/api/sentry-android-sqlite.api index c8780f1338d..6a62613dfc2 100644 --- a/sentry-android-sqlite/api/sentry-android-sqlite.api +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -21,3 +21,14 @@ public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Compan public final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper; } +public final class io/sentry/sqlite/SentrySQLiteDriver : androidx/sqlite/SQLiteDriver { + public static final field Companion Lio/sentry/sqlite/SentrySQLiteDriver$Companion; + public synthetic fun (Landroidx/sqlite/SQLiteDriver;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; + public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection; +} + +public final class io/sentry/sqlite/SentrySQLiteDriver$Companion { + public final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; +} + diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt index 1bdeb7d369c..0acf80926ba 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -4,20 +4,18 @@ import android.database.CrossProcessCursor import android.database.SQLException import io.sentry.IScopes import io.sentry.ISpan -import io.sentry.Instrumenter import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage -import io.sentry.SentryStackTraceFactory -import io.sentry.SpanDataConvention import io.sentry.SpanStatus - -private const val TRACE_ORIGIN = "auto.db.sqlite" +import io.sentry.sqlite.SQLiteSpanHelper +import io.sentry.sqlite.dbMetadataFromDatabaseName internal class SQLiteSpanManager( private val scopes: IScopes = ScopesAdapter.getInstance(), - private val databaseName: String? = null, + databaseName: String? = null, ) { - private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + private val spanHelper = SQLiteSpanHelper(scopes, dbMetadataFromDatabaseName(databaseName)) init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") @@ -45,33 +43,18 @@ internal class SQLiteSpanManager( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) - span?.spanContext?.origin = TRACE_ORIGIN + span = spanHelper.startSpan(sql, startTimestamp) span?.status = SpanStatus.OK result } catch (e: Throwable) { - span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) - span?.spanContext?.origin = TRACE_ORIGIN + span = spanHelper.startSpan(sql, startTimestamp) span?.status = SpanStatus.INTERNAL_ERROR span?.throwable = e throw e } finally { - span?.apply { - val isMainThread: Boolean = scopes.options.threadChecker.isMainThread - setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) - if (isMainThread) { - setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) - } - // if db name is null, then it's an in-memory database as per - // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.kt;l=38-42 - if (databaseName != null) { - setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite") - setData(SpanDataConvention.DB_NAME_KEY, databaseName) - } else { - setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory") - } - - finish() + span?.let { + spanHelper.applyDataToSpan(it) + it.finish() } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt new file mode 100644 index 00000000000..1038df15c13 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt @@ -0,0 +1,55 @@ +package io.sentry.sqlite + +/** + * Value associated with [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] for in-memory + * databases. + */ +internal const val DB_SYSTEM_IN_MEMORY = "in-memory" + +/** + * Value associated with [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] for SQLite + * databases. + */ +internal const val DB_SYSTEM_SQLITE = "sqlite" + +/** + * Sentinel file name that [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open] interprets as an + * in-memory database: + * https://developer.android.com/reference/androidx/sqlite/driver/AndroidSQLiteDriver. + */ +private const val IN_MEMORY_DB_FILENAME = ":memory:" + +/** Path separators matching [File.separatorChar][java.io.File.separatorChar]. */ +private val FILE_NAME_PATH_SEPARATORS = charArrayOf('/', '\\') + +internal data class DbMetadata(val name: String?, val system: String) + +/** + * Resolves metadata from the [fileName] argument to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ +internal fun dbMetadataFromFileName(fileName: String): DbMetadata { + if (fileName == IN_MEMORY_DB_FILENAME) { + return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) + } + + val trimmed = fileName.trimEnd('/', '\\') + if (trimmed.isEmpty()) { + return DbMetadata(name = null, system = DB_SYSTEM_SQLITE) + } + + val index = trimmed.lastIndexOfAny(FILE_NAME_PATH_SEPARATORS) + val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed + return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE) +} + +/** + * Resolves metadata from + * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. + */ +internal fun dbMetadataFromDatabaseName(databaseName: String?): DbMetadata = + if (databaseName == null) { + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) + } else { + DbMetadata(name = databaseName, system = DB_SYSTEM_SQLITE) + } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanHelper.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanHelper.kt new file mode 100644 index 00000000000..66adf69ce9f --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanHelper.kt @@ -0,0 +1,33 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.Instrumenter +import io.sentry.SentryDate +import io.sentry.SentryStackTraceFactory +import io.sentry.SpanDataConvention + +private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" + +/** Shared span creation and metadata for SQLite instrumentation. */ +internal class SQLiteSpanHelper(private val scopes: IScopes, private val dbMetadata: DbMetadata) { + + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + fun startSpan(sql: String, startTimestamp: SentryDate): ISpan? = + scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply { + spanContext.origin = SQLITE_TRACE_ORIGIN + } + + fun applyDataToSpan(span: ISpan) { + val isMainThread = scopes.options.threadChecker.isMainThread + span.setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) + + if (isMainThread) { + span.setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) + } + + dbMetadata.name?.let { span.setData(SpanDataConvention.DB_NAME_KEY, it) } + span.setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt new file mode 100644 index 00000000000..793848852b2 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt @@ -0,0 +1,45 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import io.sentry.SentryDate +import io.sentry.SentryLevel +import io.sentry.SentryLongDate +import io.sentry.SpanStatus + +internal class SQLiteSpanRecorder( + fileName: String, + private val scopes: IScopes = ScopesAdapter.getInstance(), +) { + + private val spanHelper = SQLiteSpanHelper(scopes, dbMetadataFromFileName(fileName)) + + /** + * Returns a start timestamp for a db.sql.query span. + * + * Exposed so callers can capture a wall-clock start before accumulating database time. + * Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace + * timeline, which is less desirable. + */ + fun startTimestamp(): SentryDate = scopes.options.dateProvider.now() + + /** Records a db.sql.query span. */ + @Suppress("TooGenericExceptionCaught") + fun recordSpan( + sql: String, + startTimestamp: SentryDate, + durationNanos: Long, + status: SpanStatus, + throwable: Throwable? = null, + ) { + try { + val span = spanHelper.startSpan(sql, startTimestamp) ?: return + throwable?.let { span.throwable = it } + spanHelper.applyDataToSpan(span) + val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos) + span.finish(status, endTimestamp) + } catch (t: Throwable) { + scopes.options.logger.log(SentryLevel.ERROR, "Failed to record SQLite span.", t) + } + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt new file mode 100644 index 00000000000..b83c74dae1b --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -0,0 +1,16 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement + +internal class SentrySQLiteConnection( + private val delegate: SQLiteConnection, + private val spanRecorder: SQLiteSpanRecorder, +) : SQLiteConnection by delegate { + + override fun prepare(sql: String): SQLiteStatement { + val statement = delegate.prepare(sql) + return statement as? SentrySQLiteStatement + ?: SentrySQLiteStatement(statement, spanRecorder, sql) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt new file mode 100644 index 00000000000..917064ab5b9 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -0,0 +1,66 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import io.sentry.ScopesAdapter +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel + +/** + * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. + * + * Example usage: + * ``` + * val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver()) + * ``` + * + * If you use Room: + * ``` + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver())) + * .build() + * ``` + * + * **Warning:** Do not use [SentrySQLiteDriver] together with + * [io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the same database file. Both wrappers + * instrument at different layers, so combining them will produce duplicate spans for every SQL + * statement. + * + * @param delegate The [SQLiteDriver] instance to delegate calls to. + */ +public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : + SQLiteDriver { + + init { + SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver") + } + + @Suppress("TooGenericExceptionCaught") + override fun open(fileName: String): SQLiteConnection { + val connection = delegate.open(fileName) + + return try { + val spanRecorder = SQLiteSpanRecorder(fileName) + // create() ensures delegate is unwrapped, so we don't protect against double-wrapping the + // connection. + SentrySQLiteConnection(connection, spanRecorder) + } catch (t: Throwable) { + ScopesAdapter.getInstance() + .options + .logger + .log( + SentryLevel.ERROR, + "Failed to instrument SQLite connection; returning uninstrumented connection.", + t, + ) + connection + } + } + + public companion object { + + @JvmStatic + public fun create(delegate: SQLiteDriver): SQLiteDriver = + delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt new file mode 100644 index 00000000000..f3c66440eb1 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -0,0 +1,81 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SentryDate +import io.sentry.SpanStatus + +/** + * Wraps a [SQLiteStatement] and records a single Sentry span covering all [step] calls for the + * statement's lifetime (until [step] iteration is complete or the statement is [reset] or + * [closed][close]). + * + * Span duration is purposefully restricted to accumulated database time, i.e., each [step] call is + * individually timed and the durations are summed. Time the application spends between steps (e.g., + * processing rows, sleeping, or doing I/O) is intentionally excluded so the span accurately + * represents how long SQLite itself was working. + * + * Not thread-safe: assumes sequential access within each SQL statement (normal SQLite usage). + */ +internal class SentrySQLiteStatement( + private val delegate: SQLiteStatement, + private val spanRecorder: SQLiteSpanRecorder, + private val sql: String, + private val nanoTimeProvider: () -> Long = System::nanoTime, +) : SQLiteStatement by delegate { + + private var firstStepTimestamp: SentryDate? = null + private var accumulatedDbNanos: Long = 0L + private var stepsComplete = false + private var closed = false + + @Suppress("TooGenericExceptionCaught") + override fun step(): Boolean { + if (stepsComplete || closed) { + return delegate.step() + } + + val beforeNanos = nanoTimeProvider() + return try { + if (firstStepTimestamp == null) { + firstStepTimestamp = spanRecorder.startTimestamp() + } + + stepsComplete = !delegate.step() + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + if (stepsComplete) { + recordSpan(SpanStatus.OK) + } + !stepsComplete + } catch (e: Throwable) { + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + recordSpan(SpanStatus.INTERNAL_ERROR, e) + throw e + } + } + + override fun reset() { + if (closed) { + return delegate.reset() + } + + try { + recordSpan(SpanStatus.OK) + } finally { + delegate.reset() + stepsComplete = false + } + } + + override fun close() { + closed = true + delegate.use { recordSpan(SpanStatus.OK) } + } + + private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) { + val start = firstStepTimestamp ?: return + val duration = accumulatedDbNanos + firstStepTimestamp = null + accumulatedDbNanos = 0L + spanRecorder.recordSpan(sql, start, duration, status, throwable) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt new file mode 100644 index 00000000000..227b9d9558c --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt @@ -0,0 +1,87 @@ +package io.sentry.sqlite + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DbMetadataTest { + + @Test + fun `dbMetadataFromFileName returns in-memory system with no db name for in-memory sentinel`() { + assertEquals( + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), + dbMetadataFromFileName(":memory:"), + ) + } + + @Test + fun `dbMetadataFromDatabaseName returns in-memory system with no db name when databaseName is null`() { + assertEquals( + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), + dbMetadataFromDatabaseName(null), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for unix path`() { + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName has no separator`() { + assertEquals( + DbMetadata(name = "tracks", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks"), + ) + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for relative path with forward slashes`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("databases/myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for windows-style path`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("C:\\Users\\app\\databases\\myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName uses last separator when both slash types are present`() { + assertEquals( + DbMetadata(name = "db.sqlite", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data\\mixed/path\\db.sqlite"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName ends with separator`() { + assertEquals( + DbMetadata(name = "databases", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name when fileName contains only separators`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("/")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("///")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("\\\\")) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name for empty fileName`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("")) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt new file mode 100644 index 00000000000..e52b30042a1 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt @@ -0,0 +1,156 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.util.thread.IThreadChecker +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SQLiteSpanRecorderTest { + + private class Fixture { + + val scopes = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut( + isTransactionActive: Boolean = true, + fileName: String = ":memory:", + ): SQLiteSpanRecorder { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + if (isTransactionActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + return SQLiteSpanRecorder(fileName, scopes) + } + } + + private val fixture = Fixture() + + @Test + fun `recordSpan records a span if a transaction is active`() { + val sut = fixture.getSut(isTransactionActive = true) + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + assertEquals(1, fixture.sentryTracer.children.size) + } + + @Test + fun `recordSpan does not record a span if no transaction is active`() { + val sut = fixture.getSut(isTransactionActive = false) + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `recordSpan creates a span with correct properties`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + sut.recordSpan("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT * FROM users", span.description) + assertEquals("auto.db.sqlite", span.spanContext.origin) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `recordSpan sets finishDate equal to startDate + durationNanos`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val durationNanos = 42_000_000L + + sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals(start, span.startDate) + assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `recordSpan attaches throwable when provided`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val exception = RuntimeException("disk I/O error") + + sut.recordSpan("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) + + val span = fixture.sentryTracer.children.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + @Test + fun `recordSpan sets db system and db name when fileName is not the in-memory sentinel`() { + val sut = fixture.getSut(fileName = "/data/data/com.example/databases/tracks.db") + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `recordSpan sets db system only when fileName is the in-memory sentinel`() { + val sut = fixture.getSut(fileName = ":memory:") + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertNull(span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `recordSpan sets blocked_main_thread to true and attaches call stack on main thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") + + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNotNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `recordSpan sets blocked_main_thread to false and does not attach a call stack on background thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") + + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `recordSpan does not throw if span recording fails`() { + val sut = fixture.getSut() + whenever(fixture.scopes.span).thenThrow(RuntimeException("span unavailable")) + + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt new file mode 100644 index 00000000000..bbd3f2458f0 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -0,0 +1,63 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.SentryOptions +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertSame +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteConnectionTest { + + private class Fixture { + + val scopes = mock() + val mockConnection = mock() + val mockStatement = mock() + lateinit var options: SentryOptions + + fun getSut(): SentrySQLiteConnection { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + whenever(mockConnection.prepare("SELECT 1")).thenReturn(mockStatement) + val spanRecorder = SQLiteSpanRecorder("test.db", scopes) + return SentrySQLiteConnection(mockConnection, spanRecorder) + } + } + + private val fixture = Fixture() + + @Test + fun `prepare returns a SentrySQLiteStatement`() { + val sut = fixture.getSut() + val statement = sut.prepare("SELECT 1") + assertIs(statement) + } + + @Test + fun `prepare with already-wrapped statement returns same instance without re-wrapping`() { + val sut = fixture.getSut() + val spanRecorder = SQLiteSpanRecorder("test.db", fixture.scopes) + val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spanRecorder, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) + + val statement = sut.prepare("SELECT 1") + + assertSame(alreadyInstrumented, statement) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut() + + sut.prepare("SELECT 1") + verify(fixture.mockConnection).prepare("SELECT 1") + + sut.close() + verify(fixture.mockConnection).close() + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt new file mode 100644 index 00000000000..0b712b27d37 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -0,0 +1,131 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Before +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteDriverTest { + + private class Fixture { + + val mockDriver = mock() + val mockConnection = mock() + + fun getSut(fileName: String): SentrySQLiteDriver { + whenever(mockDriver.open(fileName)).thenReturn(mockConnection) + return SentrySQLiteDriver.create(mockDriver) as SentrySQLiteDriver + } + } + + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun `create registers SQLiteDriver integration`() { + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + SentrySQLiteDriver.create(fixture.mockDriver) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + } + + @Test + fun `create with non-wrapped driver returns SentrySQLiteDriver`() { + val result = SentrySQLiteDriver.create(fixture.mockDriver) + assertIs(result) + } + + @Test + fun `create with already-wrapped driver returns same instance without re-wrapping`() { + val wrapped = SentrySQLiteDriver.create(fixture.mockDriver) + val doubleWrapped = SentrySQLiteDriver.create(wrapped) + assertSame(wrapped, doubleWrapped) + } + + @Test + fun `open returns SentrySQLiteConnection wrapping delegate if wrapping succeeds`() { + val driver = fixture.getSut("myapp.db") + val connection = driver.open("myapp.db") + assertIs(connection) + } + + @Test + fun `open returns the unwrapped delegate if wrapping fails`() { + val brokenScopes = mock() + val validOptions = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(brokenScopes.options) + .thenThrow(RuntimeException("Sentry options unavailable")) + .thenReturn(validOptions) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(brokenScopes) + + val driver = fixture.getSut("myapp.db") + val result = driver.open("myapp.db") + + assertSame(fixture.mockConnection, result) + verify(fixture.mockDriver).open("myapp.db") + } + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut("myapp.db") + + sut.open("myapp.db") + verify(fixture.mockDriver).open("myapp.db") + } + + // Smoke test ensuring all layers are properly wired up. + @Test + fun `full stack produces a span with correct metadata`() { + val scopes = mock() + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + val tracer = SentryTracer(TransactionContext("name", "op"), scopes) + whenever(scopes.span).thenReturn(tracer) + + val mockStatement = mock() + whenever(fixture.mockConnection.prepare("SELECT * FROM users")).thenReturn(mockStatement) + whenever(mockStatement.step()).thenReturn(true, false) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + val driver = fixture.getSut("/data/data/com.example/databases/myapp.db") + val connection = driver.open("/data/data/com.example/databases/myapp.db") + val statement = connection.prepare("SELECT * FROM users") + + assertIs(connection) + assertIs(statement) + + statement.step() + statement.step() + + val span = tracer.children.firstOrNull() + assertNotNull(span) + assertEquals("myapp.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt new file mode 100644 index 00000000000..777e999df21 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -0,0 +1,292 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SentryLongDate +import io.sentry.SpanStatus +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteStatementTest { + + private class Fixture { + val mockStatement = mock() + val mockRecorder = mock() + val startDate = SentryLongDate(1_000_000_000_000L) + val fakeClock = AtomicLong(0L) + + fun getSut(sql: String): SentrySQLiteStatement { + whenever(mockRecorder.startTimestamp()).thenReturn(startDate) + return SentrySQLiteStatement(mockStatement, mockRecorder, sql, fakeClock::getAndIncrement) + } + } + + private val fixture = Fixture() + + @Test + fun `step calls recordSpan once after iteration completes`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + sut.step() + verify(fixture.mockRecorder) + .recordSpan( + eq("SELECT * FROM users"), + eq(fixture.startDate), + any(), + eq(SpanStatus.OK), + anyOrNull(), + ) + } + + @Test + fun `step that throws an exception calls recordSpan with INTERNAL_ERROR and exception`() { + val sut = fixture.getSut("BAD SQL") + val exception = RuntimeException("db error") + whenever(fixture.mockStatement.step()).thenThrow(exception) + + assertFailsWith { sut.step() } + + verify(fixture.mockRecorder) + .recordSpan( + eq("BAD SQL"), + eq(fixture.startDate), + any(), + eq(SpanStatus.INTERNAL_ERROR), + eq(exception), + ) + } + + @Test + fun `step after exception calls recordSpan once new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()) + .thenThrow(RuntimeException("first failure")) + .thenReturn(false) + + assertFailsWith { sut.step() } + verifyCalledRecordSpan(times = 1) + + sut.step() + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `step after step iteration completes does not call recordSpan again`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true, false, false) + + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.step() + + verifyCalledRecordSpan(times = 1) + verify(fixture.mockStatement, times(3)).step() + } + + @Test + fun `reset calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.reset() + + verifyCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.reset() + verifyNeverCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after reset calls recordSpan when new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + sut.step() + + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `close calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.close() + + verifyCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.close() + verifyNeverCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + sut.step() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `reset after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.close() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `recorded duration captures step time but excludes time between steps`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()) + .thenAnswer { + fixture.fakeClock.addAndGet(10) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(20) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(30) + false + } + + sut.step() + // Simulate work done between steps. + fixture.fakeClock.addAndGet(1_000_000) + sut.step() + fixture.fakeClock.addAndGet(2_000_000) + sut.step() + + val durationCaptor = argumentCaptor() + verify(fixture.mockRecorder) + .recordSpan(any(), any(), durationCaptor.capture(), any(), anyOrNull()) + // Each step contributes its internal time (10 + 20 + 30) plus one unit from + // fakeClock::getAndIncrement between before/after reads, so total is 63. + assertEquals(63L, durationCaptor.firstValue) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut("SELECT 1") + + sut.bindBlob(0, byteArrayOf()) + verify(fixture.mockStatement).bindBlob(0, byteArrayOf()) + + sut.bindDouble(0, 1.0) + verify(fixture.mockStatement).bindDouble(0, 1.0) + + sut.bindLong(0, 1L) + verify(fixture.mockStatement).bindLong(0, 1L) + + sut.bindText(0, "text") + verify(fixture.mockStatement).bindText(0, "text") + + sut.bindNull(0) + verify(fixture.mockStatement).bindNull(0) + + sut.getDouble(0) + verify(fixture.mockStatement).getDouble(0) + + sut.getLong(0) + verify(fixture.mockStatement).getLong(0) + + sut.getText(0) + verify(fixture.mockStatement).getText(0) + + sut.isNull(0) + verify(fixture.mockStatement).isNull(0) + + sut.getColumnCount() + verify(fixture.mockStatement).getColumnCount() + + sut.getColumnName(0) + verify(fixture.mockStatement).getColumnName(0) + + sut.step() + verify(fixture.mockStatement).step() + + sut.reset() + verify(fixture.mockStatement).reset() + + sut.clearBindings() + verify(fixture.mockStatement).clearBindings() + + sut.close() + verify(fixture.mockStatement).close() + } + + private fun verifyNeverCalledRecordSpan() { + verifyCalledRecordSpan(times = 0) + } + + private fun verifyCalledRecordSpan(times: Int = 1) { + verify(fixture.mockRecorder, times(times)).recordSpan(any(), any(), any(), any(), anyOrNull()) + } +}