From 4c67ea93ef1da4700358841ec2b15524ebc68c3b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 20 May 2026 14:59:54 +0200 Subject: [PATCH 1/3] 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()) + } +} From 02edd2ae5ac3763f5d5be90c6583a985f2fae179 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 28 May 2026 15:14:38 +0200 Subject: [PATCH 2/3] Use lambda for nanoTimeProvider default Method reference `System::nanoTime` compiles to FunctionReferenceImpl, which breaks R8 in the SDK size test app. --- .../src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt | 6 +++--- .../src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 917064ab5b9..284823195f5 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -22,9 +22,9 @@ import io.sentry.SentryLevel * ``` * * **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. + * [SentrySupportSQLiteOpenHelper][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. */ 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 index f3c66440eb1..425aa6d2592 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -20,7 +20,7 @@ internal class SentrySQLiteStatement( private val delegate: SQLiteStatement, private val spanRecorder: SQLiteSpanRecorder, private val sql: String, - private val nanoTimeProvider: () -> Long = System::nanoTime, + private val nanoTimeProvider: () -> Long = { System.nanoTime() }, ) : SQLiteStatement by delegate { private var firstStepTimestamp: SentryDate? = null From 37591e79e34e0854a31607b63e55251820a8d3f8 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 3 Jun 2026 05:21:23 +0200 Subject: [PATCH 3/3] Address Roman's comments - Merge SQLiteSpanHelper + SQLiteSpanRecorder into a single SQLiteSpanInstrumentation class. - DRY out reference to file name path separators. --- sentry-android-sqlite/README.md | 4 +- .../android/sqlite/SQLiteSpanManager.kt | 22 ++--- .../main/java/io/sentry/sqlite/DbMetadata.kt | 2 +- .../java/io/sentry/sqlite/SQLiteSpanHelper.kt | 33 ------- .../sqlite/SQLiteSpanInstrumentation.kt | 85 +++++++++++++++++++ .../io/sentry/sqlite/SQLiteSpanRecorder.kt | 45 ---------- .../sentry/sqlite/SentrySQLiteConnection.kt | 5 +- .../io/sentry/sqlite/SentrySQLiteDriver.kt | 8 +- .../io/sentry/sqlite/SentrySQLiteStatement.kt | 6 +- ...st.kt => SQLiteSpanInstrumentationTest.kt} | 49 +++++++++-- .../sqlite/SentrySQLiteConnectionTest.kt | 8 +- .../sqlite/SentrySQLiteStatementTest.kt | 15 ++-- 12 files changed, 157 insertions(+), 125 deletions(-) delete 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/SQLiteSpanInstrumentation.kt delete mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt rename sentry-android-sqlite/src/test/java/io/sentry/sqlite/{SQLiteSpanRecorderTest.kt => SQLiteSpanInstrumentationTest.kt} (71%) diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md index c6bd8b68369..c831320d608 100644 --- a/sentry-android-sqlite/README.md +++ b/sentry-android-sqlite/README.md @@ -13,8 +13,8 @@ Please consult the [Sentry Docs](https://docs.sentry.io/platforms/android/integr 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. +- **`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 `SQLiteSpanManager` wrapper live here. +- **`io.sentry.sqlite`**: Code whose contract depends only on the multiplatform `androidx.sqlite.*` interfaces (e.g., `SQLiteDriver` and `SQLiteConnection`). `SentrySQLiteDriver` and shared span instrumentation via `SQLiteSpanInstrumentation` 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. 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 0acf80926ba..3495d3a71f0 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 @@ -3,19 +3,17 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor import android.database.SQLException import io.sentry.IScopes -import io.sentry.ISpan import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SpanStatus -import io.sentry.sqlite.SQLiteSpanHelper -import io.sentry.sqlite.dbMetadataFromDatabaseName +import io.sentry.sqlite.SQLiteSpanInstrumentation internal class SQLiteSpanManager( private val scopes: IScopes = ScopesAdapter.getInstance(), databaseName: String? = null, ) { - private val spanHelper = SQLiteSpanHelper(scopes, dbMetadataFromDatabaseName(databaseName)) + private val spans = SQLiteSpanInstrumentation.fromDatabaseName(databaseName, scopes) init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") @@ -31,8 +29,8 @@ internal class SQLiteSpanManager( @Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST") @Throws(SQLException::class) fun performSql(sql: String, operation: () -> T): T { - val startTimestamp = scopes.getOptions().dateProvider.now() - var span: ISpan? = null + val startTimestamp = spans.startTimestamp() + return try { val result = operation() /* @@ -43,19 +41,11 @@ internal class SQLiteSpanManager( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - span = spanHelper.startSpan(sql, startTimestamp) - span?.status = SpanStatus.OK + spans.recordSpan(sql, startTimestamp, SpanStatus.OK) result } catch (e: Throwable) { - span = spanHelper.startSpan(sql, startTimestamp) - span?.status = SpanStatus.INTERNAL_ERROR - span?.throwable = e + spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e) throw e - } finally { - 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 index 1038df15c13..8892e5c47a3 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt @@ -33,7 +33,7 @@ internal fun dbMetadataFromFileName(fileName: String): DbMetadata { return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) } - val trimmed = fileName.trimEnd('/', '\\') + val trimmed = fileName.trimEnd { it in FILE_NAME_PATH_SEPARATORS } if (trimmed.isEmpty()) { return DbMetadata(name = null, 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 deleted file mode 100644 index 66adf69ce9f..00000000000 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanHelper.kt +++ /dev/null @@ -1,33 +0,0 @@ -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/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt new file mode 100644 index 00000000000..53df4de921a --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt @@ -0,0 +1,85 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.Instrumenter +import io.sentry.ScopesAdapter +import io.sentry.SentryDate +import io.sentry.SentryLongDate +import io.sentry.SentryStackTraceFactory +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus + +private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" + +/** Shared span creation and metadata for SQLite instrumentation. */ +internal class SQLiteSpanInstrumentation( + private val scopes: IScopes, + private val dbMetadata: DbMetadata, +) { + + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + /** + * 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 from [startTimestamp] to the moment of invocation. */ + fun recordSpan( + sql: String, + startTimestamp: SentryDate, + status: SpanStatus, + throwable: Throwable? = null, + ) { + recordSpan(sql, startTimestamp, endTimestamp = null, status, throwable) + } + + /** Records a `db.sql.query` span from [startTimestamp] to [startTimestamp] + [durationNanos]. */ + fun recordSpan( + sql: String, + startTimestamp: SentryDate, + durationNanos: Long, + status: SpanStatus, + throwable: Throwable? = null, + ) { + val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos) + recordSpan(sql, startTimestamp, endTimestamp, status, throwable) + } + + private fun recordSpan( + sql: String, + startTimestamp: SentryDate, + endTimestamp: SentryDate?, + status: SpanStatus, + throwable: Throwable?, + ) { + scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply { + spanContext.origin = SQLITE_TRACE_ORIGIN + throwable?.let { this.throwable = it } + + val isMainThread = scopes.options.threadChecker.isMainThread + setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) + + if (isMainThread) { + setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) + } + + dbMetadata.name?.let { setData(SpanDataConvention.DB_NAME_KEY, it) } + setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system) + finish(status, endTimestamp) + } + } + + companion object { + + fun fromDatabaseName(databaseName: String?, scopes: IScopes = ScopesAdapter.getInstance()) = + SQLiteSpanInstrumentation(scopes, dbMetadataFromDatabaseName(databaseName)) + + fun fromFileName(fileName: String, scopes: IScopes = ScopesAdapter.getInstance()) = + SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName)) + } +} 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 deleted file mode 100644 index 793848852b2..00000000000 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt +++ /dev/null @@ -1,45 +0,0 @@ -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 index b83c74dae1b..45ee9a39b27 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -5,12 +5,11 @@ import androidx.sqlite.SQLiteStatement internal class SentrySQLiteConnection( private val delegate: SQLiteConnection, - private val spanRecorder: SQLiteSpanRecorder, + private val spans: SQLiteSpanInstrumentation, ) : SQLiteConnection by delegate { override fun prepare(sql: String): SQLiteStatement { val statement = delegate.prepare(sql) - return statement as? SentrySQLiteStatement - ?: SentrySQLiteStatement(statement, spanRecorder, sql) + return statement as? SentrySQLiteStatement ?: SentrySQLiteStatement(statement, spans, 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 index 284823195f5..0403d1ff4d7 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -40,10 +40,10 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite 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) + val spans = SQLiteSpanInstrumentation.fromFileName(fileName) + // create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping + // the connection. + SentrySQLiteConnection(connection, spans) } catch (t: Throwable) { ScopesAdapter.getInstance() .options 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 index 425aa6d2592..c5a27b654f3 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -18,7 +18,7 @@ import io.sentry.SpanStatus */ internal class SentrySQLiteStatement( private val delegate: SQLiteStatement, - private val spanRecorder: SQLiteSpanRecorder, + private val spans: SQLiteSpanInstrumentation, private val sql: String, private val nanoTimeProvider: () -> Long = { System.nanoTime() }, ) : SQLiteStatement by delegate { @@ -37,7 +37,7 @@ internal class SentrySQLiteStatement( val beforeNanos = nanoTimeProvider() return try { if (firstStepTimestamp == null) { - firstStepTimestamp = spanRecorder.startTimestamp() + firstStepTimestamp = spans.startTimestamp() } stepsComplete = !delegate.step() @@ -76,6 +76,6 @@ internal class SentrySQLiteStatement( val duration = accumulatedDbNanos firstStepTimestamp = null accumulatedDbNanos = 0L - spanRecorder.recordSpan(sql, start, duration, status, throwable) + spans.recordSpan(sql, start, duration, status, throwable) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt similarity index 71% rename from sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt rename to sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt index e52b30042a1..ead123a190b 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt @@ -16,7 +16,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class SQLiteSpanRecorderTest { +class SQLiteSpanInstrumentationTest { private class Fixture { @@ -27,14 +27,14 @@ class SQLiteSpanRecorderTest { fun getSut( isTransactionActive: Boolean = true, fileName: String = ":memory:", - ): SQLiteSpanRecorder { + ): SQLiteSpanInstrumentation { 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) + return SQLiteSpanInstrumentation.fromFileName(fileName, scopes) } } @@ -147,10 +147,47 @@ class SQLiteSpanRecorderTest { } @Test - fun `recordSpan does not throw if span recording fails`() { + fun `recordSpan without a duration finishes the span at the time of invocation`() { val sut = fixture.getSut() - whenever(fixture.scopes.span).thenThrow(RuntimeException("span unavailable")) + val start = sut.startTimestamp() - sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + sut.recordSpan("SELECT 1", start, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.OK, span.status) + // Unlike the duration overload, no synthetic end timestamp is supplied; the span finishes at + // "now", i.e. at or after its start. + assertTrue(span.finishDate!!.nanoTimestamp() >= start.nanoTimestamp()) + } + + @Test + fun `fromFileName sets db name from fileName`() { + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(fixture.scopes.options).thenReturn(options) + fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + + val sut = SQLiteSpanInstrumentation.fromFileName("tracks.db", fixture.scopes) + sut.recordSpan("SELECT 1", sut.startTimestamp(), 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 `fromDatabaseName sets db name from databaseName`() { + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(fixture.scopes.options).thenReturn(options) + fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + + val sut = SQLiteSpanInstrumentation.fromDatabaseName("tracks.db", fixture.scopes) + sut.recordSpan("SELECT 1", sut.startTimestamp(), 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]) } } 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 index bbd3f2458f0..b405d054f03 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -24,8 +24,8 @@ class SentrySQLiteConnectionTest { 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) + val spans = SQLiteSpanInstrumentation.fromFileName("test.db", scopes) + return SentrySQLiteConnection(mockConnection, spans) } } @@ -41,8 +41,8 @@ class SentrySQLiteConnectionTest { @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") + val spans = SQLiteSpanInstrumentation.fromFileName("test.db", fixture.scopes) + val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spans, "SELECT 1") whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) val statement = sut.prepare("SELECT 1") 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 index 777e999df21..6691910e358 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -20,13 +20,13 @@ class SentrySQLiteStatementTest { private class Fixture { val mockStatement = mock() - val mockRecorder = mock() + val mockSpans = 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) + whenever(mockSpans.startTimestamp()).thenReturn(startDate) + return SentrySQLiteStatement(mockStatement, mockSpans, sql, fakeClock::getAndIncrement) } } @@ -40,7 +40,7 @@ class SentrySQLiteStatementTest { sut.step() verifyNeverCalledRecordSpan() sut.step() - verify(fixture.mockRecorder) + verify(fixture.mockSpans) .recordSpan( eq("SELECT * FROM users"), eq(fixture.startDate), @@ -58,7 +58,7 @@ class SentrySQLiteStatementTest { assertFailsWith { sut.step() } - verify(fixture.mockRecorder) + verify(fixture.mockSpans) .recordSpan( eq("BAD SQL"), eq(fixture.startDate), @@ -225,8 +225,7 @@ class SentrySQLiteStatementTest { sut.step() val durationCaptor = argumentCaptor() - verify(fixture.mockRecorder) - .recordSpan(any(), any(), durationCaptor.capture(), any(), anyOrNull()) + verify(fixture.mockSpans).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) @@ -287,6 +286,6 @@ class SentrySQLiteStatementTest { } private fun verifyCalledRecordSpan(times: Int = 1) { - verify(fixture.mockRecorder, times(times)).recordSpan(any(), any(), any(), any(), anyOrNull()) + verify(fixture.mockSpans, times(times)).recordSpan(any(), any(), any(), any(), anyOrNull()) } }