From 4c67ea93ef1da4700358841ec2b15524ebc68c3b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 20 May 2026 14:59:54 +0200 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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()) } } From e689907a5b221ce5860d7b7d91f2ad05d59fe24c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 2 Jun 2026 23:44:22 +0200 Subject: [PATCH 04/15] Bump androidx.sqlite to 2.6.2 to pick up new hasConnectionPool method --- gradle/libs.versions.toml | 2 +- sentry-android-sqlite/api/sentry-android-sqlite.api | 2 ++ .../src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt | 3 +++ sentry-android-sqlite/src/test/AndroidManifest.xml | 6 ++++++ .../test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt | 6 ++++++ 5 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 sentry-android-sqlite/src/test/AndroidManifest.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 12e24536d7e..7d9f1b668c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -94,7 +94,7 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } diff --git a/sentry-android-sqlite/api/sentry-android-sqlite.api b/sentry-android-sqlite/api/sentry-android-sqlite.api index 6a62613dfc2..71fff6d51d5 100644 --- a/sentry-android-sqlite/api/sentry-android-sqlite.api +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -25,6 +25,8 @@ public final class io/sentry/sqlite/SentrySQLiteDriver : androidx/sqlite/SQLiteD 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 getHasConnectionPool ()Z + public synthetic fun hasConnectionPool ()Z public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection; } 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 0403d1ff4d7..693cd90d359 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 @@ -35,6 +35,9 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver") } + override val hasConnectionPool: Boolean + get() = delegate.hasConnectionPool + @Suppress("TooGenericExceptionCaught") override fun open(fileName: String): SQLiteConnection { val connection = delegate.open(fileName) diff --git a/sentry-android-sqlite/src/test/AndroidManifest.xml b/sentry-android-sqlite/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..fbbd96c701f --- /dev/null +++ b/sentry-android-sqlite/src/test/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + 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 index 0b712b27d37..4e068f9afe7 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -93,6 +93,12 @@ class SentrySQLiteDriverTest { fun `all calls are propagated to the delegate`() { val sut = fixture.getSut("myapp.db") + whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) + assertTrue(sut.hasConnectionPool) + + whenever(fixture.mockDriver.hasConnectionPool).thenReturn(false) + assertFalse(sut.hasConnectionPool) + sut.open("myapp.db") verify(fixture.mockDriver).open("myapp.db") } From f2207a59c3b6828b87f94fc939e48b5681158f34 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 5 Jun 2026 12:49:33 +0200 Subject: [PATCH 05/15] Guard SQLiteDriver.hasConnectionPool for sqlite 2.5.x delegates --- CHANGELOG.md | 1 + .../io/sentry/sqlite/SentrySQLiteDriver.kt | 8 ++++- .../src/test/AndroidManifest.xml | 11 ++++-- .../sentry/sqlite/SentrySQLiteDriverTest.kt | 36 +++++++++++-------- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85454013b26..30b7e2a85bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - 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(...)` + - You'll need `androidx.sqlite:sqlite` (2.5.0+) on your app's classpath (Room usually provides it for you). androidx.sqlite 2.6.0+ requires minSdk 23. - See https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/ for more details, including info about migrating from `SentrySupportSQLiteOpenHelper` ## 8.43.1 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 693cd90d359..7c7e24ac075 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 @@ -36,7 +36,13 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite } override val hasConnectionPool: Boolean - get() = delegate.hasConnectionPool + get() = + try { + delegate.hasConnectionPool + } catch (_: LinkageError) { + // Delegates on androidx.sqlite < 2.6.0 won't have a hasConnectionPool property. + false + } @Suppress("TooGenericExceptionCaught") override fun open(fileName: String): SQLiteConnection { diff --git a/sentry-android-sqlite/src/test/AndroidManifest.xml b/sentry-android-sqlite/src/test/AndroidManifest.xml index fbbd96c701f..967265a1f16 100644 --- a/sentry-android-sqlite/src/test/AndroidManifest.xml +++ b/sentry-android-sqlite/src/test/AndroidManifest.xml @@ -1,6 +1,13 @@ - - + + 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 index 4e068f9afe7..9b2345a975f 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -12,6 +12,7 @@ import io.sentry.SpanDataConvention import io.sentry.TransactionContext import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull @@ -63,6 +64,27 @@ class SentrySQLiteDriverTest { assertSame(wrapped, doubleWrapped) } + @Test + fun `hasConnectionPool forwards delegate value when supported`() { + whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertTrue(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool returns false when delegate throws LinkageError`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(AbstractMethodError()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFalse(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool does not catch non-LinkageErrors`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(IllegalStateException()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFailsWith { sut.hasConnectionPool } + } + @Test fun `open returns SentrySQLiteConnection wrapping delegate if wrapping succeeds`() { val driver = fixture.getSut("myapp.db") @@ -89,20 +111,6 @@ class SentrySQLiteDriverTest { } } - @Test - fun `all calls are propagated to the delegate`() { - val sut = fixture.getSut("myapp.db") - - whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) - assertTrue(sut.hasConnectionPool) - - whenever(fixture.mockDriver.hasConnectionPool).thenReturn(false) - assertFalse(sut.hasConnectionPool) - - 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`() { From d4ffab7b9721ffb495d7bb1c10ef6d6444a39462 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 2 Jun 2026 05:40:29 +0200 Subject: [PATCH 06/15] chore(android-sqlite): Add SQLite samples to sentry-samples-android Adds our SQLite integrations to sentry-android-samples (`SentrySQLiteDriver` and `SentrySupportOpenSQLiteHelper`). The entry point is `SQLiteActivity`. Example SQL statements are identical across integrations so we can observe similarities / differences in how they handle spans. Users can exercise the integrations directly or via Room or SQLDelight. --- gradle/libs.versions.toml | 31 +- .../sentry-samples-android/README.md | 27 + .../sentry-samples-android/build.gradle.kts | 40 +- .../src/main/AndroidManifest.xml | 8 + .../io/sentry/samples/android/MainActivity.kt | 14 + .../sentry/samples/android/MyApplication.java | 3 + .../samples/android/sqlite/DisplayInfo.kt | 106 ++++ .../sentry/samples/android/sqlite/Room2Dao.kt | 42 ++ .../sentry/samples/android/sqlite/Room3Dao.kt | 35 ++ .../samples/android/sqlite/SQLiteActivity.kt | 577 ++++++++++++++++++ .../samples/android/sqlite/SampleDatabases.kt | 214 +++++++ .../io/sentry/samples/android/sqlite/Song.sq | 17 + .../samples/android/sqlite/SqlStatements.kt | 224 +++++++ .../samples/android/sqlite/UiLoadActivity.kt | 64 ++ .../samples/android/sqlite/UiLoadScreen.kt | 110 ++++ 15 files changed, 1501 insertions(+), 11 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/README.md create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c286dc794f..710a794218c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,16 +5,19 @@ androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" asyncProfiler = "4.2" +camerax = "1.4.0" composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" +gummyBears = "0.12.0" jacoco = "0.8.7" jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "2.2.0" kotlinSpring7 = "2.2.0" kotlin-compatible-version = "1.9" +ksp = "2.2.0-2.0.2" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" @@ -22,6 +25,7 @@ nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin okhttp = "4.9.2" +openfeature = "1.18.2" otel = "1.60.1" otelInstrumentation = "2.26.0" otelInstrumentationAlpha = "2.26.0-alpha" @@ -29,18 +33,21 @@ otelInstrumentationAlpha = "2.26.0-alpha" otelSemanticConventions = "1.40.0" otelSemanticConventionsAlpha = "1.40.0-alpha" retrofit = "2.9.0" +room2 = "2.8.4" +room3 = "3.0.0-alpha06" +sqlite = "2.6.2" +sqliteAlpha = "2.7.0-alpha06" # Required by Room3 3.0.0-alpha* slf4j = "1.7.30" +spotless = "8.4.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.0.0" +sqldelight = "2.3.2" + # Android targetSdk = "36" compileSdk = "36" minSdk = "21" -spotless = "8.4.0" -gummyBears = "0.12.0" -camerax = "1.4.0" -openfeature = "1.18.2" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -50,6 +57,7 @@ kotlin-jvm-spring7 = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinSpr kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } @@ -64,6 +72,7 @@ vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.3 springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } gretty = { id = "org.gretty", version = "4.0.0" } animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } sentry = { id = "io.sentry.android.gradle", version = "6.6.0"} @@ -94,7 +103,14 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } +androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteAlpha" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteAlpha" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } @@ -207,6 +223,7 @@ springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-star springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } springboot4-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot4" } springboot4-starter-kafka = { module = "org.springframework.boot:spring-boot-starter-kafka", version.ref = "springboot4" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature @@ -249,3 +266,7 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } + +[bundles] +androidx-room2 = ["androidx-room-runtime", "androidx-room-ktx"] +androidx-sqlite-drivers = ["androidx-sqlite-bundled", "androidx-sqlite-framework"] diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md new file mode 100644 index 00000000000..b2efa441c33 --- /dev/null +++ b/sentry-samples/sentry-samples-android/README.md @@ -0,0 +1,27 @@ +# Sentry Sample Android App + +Sample application demonstrating how to use the Sentry Android SDK, including core functionality (error reporting, tracing, session replay, profiling) and integrations (Compose, OkHttp, SQLDelight, etc.). + +## How to run it? + +Install the app on your device or emulator: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +or simply open the project in Android Studio and run the `sentry-samples-android` configuration. + +## Viewing events locally + +Debug builds enable SDK debug logging, so captured envelopes are printed to logcat (tag `Sentry`): + +``` +adb logcat -s Sentry +``` + +## Viewing events on Sentry UI + +By default, events appear under the [sentry-sdk test project](https://sentry-sdks.sentry.io/issues/?project=5428559). +To redirect them to your own project, replace the test DSN (i.e., the `io.sentry.dsn` `meta-data` value) +in `src/main/AndroidManifest.xml` with your own. diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index ed8cea25661..e7122028a92 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -7,6 +7,8 @@ plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.sqldelight) } android { @@ -15,7 +17,8 @@ android { defaultConfig { applicationId = "io.sentry.samples.android" - minSdk = libs.versions.minSdk.get().toInt() + // androidx.sqlite 2.6+ require minSdk 23; the Sentry SDK still supports 21. + minSdk = 23 targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 2 versionName = project.version.toString() @@ -90,7 +93,13 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + // Java 11 b/c androidx.room3 requires it. + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) @@ -116,6 +125,17 @@ android { @Suppress("UnstableApiUsage") packagingOptions { jniLibs { useLegacyPackaging = true } } } +sqldelight { + databases { + create("SampleSQLDelightDatabase") { + packageName.set("io.sentry.samples.android.sqlite") + // Keep .sq files next to the hand-written Kotlin (src/main/java/.../sqlite) instead of the + // default src/main/sqldelight source root. + srcDirs("src/main/java") + } + } +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) @@ -123,6 +143,7 @@ dependencies { implementation(projects.sentryAndroid) implementation(projects.sentryAndroidFragment) + implementation(projects.sentryAndroidSqlite) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) implementation(projects.sentryKotlinExtensions) @@ -148,17 +169,24 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.recyclerview) implementation(libs.androidx.browser) + implementation(libs.androidx.room3.runtime) + implementation(libs.bundles.androidx.room2) + implementation(libs.bundles.androidx.sqlite.drivers) + implementation(libs.camerax.camera2) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.lottie.compose) implementation(libs.retrofit) implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) + implementation(libs.sqldelight.android.driver) implementation(libs.timber) - implementation(libs.camerax.core) - implementation(libs.camerax.camera2) - implementation(libs.camerax.lifecycle) - implementation(libs.camerax.view) + + ksp(libs.androidx.room.compiler) + ksp(libs.androidx.room3.compiler) debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e5b5ed2250b..23ef7e62c77 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -92,6 +92,14 @@ android:name=".TriggerHttpRequestActivity" android:exported="false" /> + + + + ) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity::class], version = 1, exportSchema = false) +abstract class SampleRoom2Database : RoomDatabase() { + + abstract fun songDao(): SongDao +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt new file mode 100644 index 00000000000..2a7e3575179 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt @@ -0,0 +1,35 @@ +package io.sentry.samples.android.sqlite + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.Insert +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.RoomDatabase + +@Entity(tableName = "song") +data class SongEntity3( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + val artist: String, +) + +@Dao +interface SongDao3 { + + @Insert suspend fun insert(song: SongEntity3) + + /** Batch insert: Room runs all rows in a single transaction, reusing one compiled statement. */ + @Insert suspend fun insertAll(songs: List) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int +} + +@Database(entities = [SongEntity3::class], version = 1, exportSchema = false) +abstract class SampleRoom3Database : RoomDatabase() { + + abstract fun songDao(): SongDao3 +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt new file mode 100644 index 00000000000..9e5c4f4f87b --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -0,0 +1,577 @@ +package io.sentry.samples.android.sqlite + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val SentryPink = Color(0xFFC85B9C) +private val SentryPurple = Color(0xFF7B52FB) +private val SentryRed = Color(0xFFF55459) + +/** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ +private const val INSTRUCTIONS = + "Tap a button to execute a SQL statement in its own transaction; long press to run it in a ui.load transaction." + +/** Start state of the "SQL run" box. */ +private const val SQL_DETAIL_HINT = "Tap a button above to see the SQL it runs…" + +private val TOGGLE_SECTION_GAP = 24.dp + +private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 + +private val SECTION_HEADER_HEIGHT = 28.dp + +/** Which sentry-android-sqlite integration the demo buttons currently target. */ +private enum class Integration(val color: Color, val apiName: String) { + DRIVER(SentryPurple, "SQLiteDriver"), + OPEN_HELPER(SentryPink, "SupportSQLiteOpenHelper"), +} + +/** + * How one demo button behaves for a given integration: which [SqlStatements] work it runs ([demo]), + * the name/op of the manual transaction a tap wraps it in, and the SQL summary shown in the detail + * panel ([displayInfo]). + */ +private class DemoVariant( + val demo: SqlDemo, + val transactionName: String, + val op: String, + val displayInfo: DisplayInfo, +) + +/** + * A single demo button in the list. [driver] / [openHelper] hold the variant for each integration; + * a null variant means the row doesn't apply to that integration and renders dimmed, explaining why + * on click (Room 3 is driver-only; SQLDelight is open-helper-only). + */ +private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?) + +// The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual +// SQL lives in SqlStatements, dispatched by id. +private val DEMO_ROWS = + listOf( + DemoRow( + label = "Direct (no library)", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_DIRECT, + transactionName = "SentrySQLiteDriver — Direct", + op = "db.sql.driver-direct", + displayInfo = DRIVER_DIRECT, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_DIRECT, + transactionName = "SentrySupportSQLiteOpenHelper — Direct", + op = "db.sql.openhelper-direct", + displayInfo = OPENHELPER_DIRECT, + ), + ), + DemoRow( + label = "Room 2", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM2, + transactionName = "SentrySQLiteDriver — Room 2", + op = "db.sql.driver-room2", + displayInfo = DRIVER_ROOM2, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_ROOM, + transactionName = "SentrySupportSQLiteOpenHelper — Room", + op = "db.sql.openhelper-room", + displayInfo = OPENHELPER_ROOM, + ), + ), + DemoRow( + label = "Room 3", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM3, + transactionName = "SentrySQLiteDriver — Room 3", + op = "db.sql.driver-room3", + displayInfo = DRIVER_ROOM3, + ), + openHelper = null, // Room 3 only runs on the SQLiteDriver path. + ), + DemoRow( + label = "SQLDelight", + driver = null, // SQLDelight's AndroidSqliteDriver is built on SupportSQLiteOpenHelper. + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_SQLDELIGHT, + transactionName = "SentrySupportSQLiteOpenHelper — SQLDelight", + op = "db.sql.openhelper-sqldelight", + displayInfo = OPENHELPER_SQLDELIGHT, + ), + ), + ) + +/** + * Activity that lets us exercise our two `sentry-android-sqlite` integrations + * ([SentrySQLiteDriver][io.sentry.sqlite.SentrySQLiteDriver] and + * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper]), both + * directly and via Room or SQLDelight. + * + * Example SQL statements are deliberately identical across integrations so we can identify + * similarities and differences in their transaction / span support. + */ +class SQLiteActivity : ComponentActivity() { + + private var latestResult by mutableStateOf("") + private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) + private var heavyWork by mutableStateOf(false) + + /** + * When enabled, every per-button transaction in one screen visit continues [screenTraceHeader], + * so they all share a trace ("session"-like). When disabled (the default), each tap is the root + * of its own trace, which renders as a standalone waterfall scaled to that one transaction — + * easier to read how time is allocated among its spans. + */ + private var shareScreenTrace by mutableStateOf(false) + + /** Which integration the demo buttons target. Switching it disables the rows that don't apply. */ + private var integration by mutableStateOf(Integration.DRIVER) + + /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ + private var runTick by mutableStateOf(0) + + /** + * The shared trace used when [shareScreenTrace] is enabled: one trace per visit to this screen. + * onResume() generates a fresh one each time the screen is (re)entered. + */ + private var screenTraceHeader = newScreenTrace() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Surface { + Column( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val screenHeightDp = LocalConfiguration.current.screenHeightDp + // A small gap below the screen title that grows with screen height and collapses to 0 + // on short screens, so the title isn't crowded against "Configure it" on tall devices. + val titleGap = + (((((screenHeightDp / 4) - 48) / 3).coerceAtLeast(0).dp + TOGGLE_SECTION_GAP) / 2 - + SECTION_HEADER_HEIGHT) + .coerceAtLeast(0.dp) + + // Pulse the "Under the hood" outline in the integration color whenever a tap runs SQL. + val shimmer = remember { Animatable(0f) } + LaunchedEffect(runTick) { + if (runTick == 0) return@LaunchedEffect + shimmer.animateTo( + targetValue = 0f, + animationSpec = + keyframes { + durationMillis = 900 + 0f at 0 + 1f at 200 + 0.4f at 450 + 1f at 650 + 0f at 900 + }, + ) + } + + val detailOutline = + lerp(MaterialTheme.colorScheme.outline, integration.color, shimmer.value) + + Text(text = "SQLite Instrumentation", style = MaterialTheme.typography.headlineSmall) + + Spacer(Modifier.height(titleGap)) + + SectionHeader("Configure it") + + val openHelper = integration == Integration.OPEN_HELPER + val integrationSwitchColors = + SwitchDefaults.colors( + checkedTrackColor = SentryPink, + checkedBorderColor = SentryPink, + uncheckedTrackColor = SentryPurple, + uncheckedBorderColor = SentryPurple, + uncheckedThumbColor = Color.White, + ) + val controlSwitchColors = + SwitchDefaults.colors( + checkedTrackColor = Color.Black, + checkedBorderColor = Color.Black, + ) + ToggleRow( + label = if (openHelper) "SentrySupportSQLiteOpenHelper" else "SentrySQLiteDriver", + checked = openHelper, + labelColor = if (openHelper) SentryPink else SentryPurple, + switchColors = integrationSwitchColors, + ) { + integration = if (it) Integration.OPEN_HELPER else Integration.DRIVER + // Switching integration starts a fresh comparison: clear the detail box and result. + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + } + ToggleRow( + label = if (heavyWork) "Heavy app-level work" else "No app-level work", + checked = heavyWork, + switchColors = controlSwitchColors, + ) { + heavyWork = it + } + ToggleRow( + label = + if (shareScreenTrace) "Single trace for all button clicks" + else "Separate trace per button click", + checked = shareScreenTrace, + switchColors = controlSwitchColors, + ) { + shareScreenTrace = it + } + + SectionHeader("Run it", topPadding = CONTROL_SECTION_GAP) { HelpTooltip() } + + // One consolidated list of demo buttons. Each row dispatches to the selected + // integration's variant; a row that doesn't apply explains why via a toast (see + // [DemoRowButton]). + DEMO_ROWS.forEach { row -> + val variant = if (integration == Integration.DRIVER) row.driver else row.openHelper + DemoRowButton( + label = row.label, + color = integration.color, + variant = variant, + disabledReason = "${row.label} doesn't use the ${integration.apiName}", + ) + } + + ResetButton() + + // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from + // the detail output. + SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + // The latest run result (row counts, errors). Hidden until the first run. + if (latestResult.isNotEmpty()) { + Text( + text = latestResult, + style = MaterialTheme.typography.bodyMedium, + color = if (latestResult.contains("failed")) SentryRed else Color.Unspecified, + ) + } + DetailField("SQL run", sqlDetail, borderColor = detailOutline) + } + } + } + } + } + + override fun onResume() { + super.onResume() + // Start a new trace each time the user (re)enters the screen, so each visit is its own session. + screenTraceHeader = newScreenTrace() + } + + /** Run the variant's SQL statement inside a manual, scope-bound transaction. */ + private fun onTap(variant: DemoVariant) { + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + runTick++ // shimmer the detail box outline in the integration color + + lifecycleScope.launch { + latestResult = + withContext(Dispatchers.IO) { + runInTransaction(variant.transactionName, variant.op) { + SqlStatements.execute(applicationContext, variant.demo, heavyWork) + } + } + } + } + + /** + * Run the variant's SQL statement in [UiLoadActivity] with no manual transaction, so its auto + * `ui.load` transaction owns the spans. + */ + private fun onLongPress(variant: DemoVariant) { + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + latestResult = "Opened the auto-load screen — its ui.load transaction owns the db spans." + startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) + } + + /** + * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the + * label inherits the default text color; the integration toggle passes its pink/purple instead. + */ + @androidx.compose.runtime.Composable + private fun ToggleRow( + label: String, + checked: Boolean, + modifier: Modifier = Modifier, + labelColor: Color = Color.Unspecified, + switchColors: SwitchColors = SwitchDefaults.colors(), + onCheckedChange: (Boolean) -> Unit, + ) { + // Constrain the row height: a Switch otherwise reserves ~48dp, leaving a large gap between the + // toggles. 32dp keeps them about one line of text apart. + Row(modifier = modifier.height(32.dp), verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = switchColors, + modifier = Modifier.scale(0.75f), + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = labelColor, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + + @androidx.compose.runtime.Composable + private fun SectionHeader( + title: String, + topPadding: Dp = 8.dp, + trailing: (@androidx.compose.runtime.Composable () -> Unit)? = null, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + trailing?.invoke() + } + HorizontalDivider(thickness = 1.dp, modifier = Modifier.padding(top = 4.dp)) + } + } + + /** + * A circled "?" next to the "Run it" header. Tapping it briefly shows the [INSTRUCTIONS] in a + * tooltip that auto-dismisses after a few seconds. + */ + @OptIn(ExperimentalMaterial3Api::class) + @androidx.compose.runtime.Composable + private fun HelpTooltip() { + val tooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + LaunchedEffect(tooltipState.isVisible) { + if (tooltipState.isVisible) { + delay(4000) + tooltipState.dismiss() + } + } + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(INSTRUCTIONS) } }, + state = tooltipState, + ) { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = "What do the buttons do?", + tint = Color.Gray, + modifier = + Modifier.padding(start = 8.dp).size(20.dp).clickable { + scope.launch { tooltipState.show() } + }, + ) + } + } + + /** + * A filled button that runs [variant] on tap (manual transaction) or long-press (ui.load). It's a + * [Surface] rather than a [Button] because Material3's Button has no long-press hook; the + * [combinedClickable] modifier gives us both. + * + * A null [variant] means the row doesn't apply to the selected integration: the button renders + * dimmed and, when clicked, explains why via a toast ([disabledReason]) instead of running. + */ + @OptIn(ExperimentalFoundationApi::class) + @androidx.compose.runtime.Composable + private fun DemoRowButton( + label: String, + color: Color, + variant: DemoVariant?, + disabledReason: String, + ) { + val context = LocalContext.current + val enabled = variant != null + val explain = { Toast.makeText(context, disabledReason, Toast.LENGTH_SHORT).show() } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = ButtonDefaults.shape, + color = if (enabled) color else color.copy(alpha = 0.26f), + contentColor = Color.White, + ) { + Box( + modifier = + Modifier.combinedClickable( + onClick = { if (variant != null) onTap(variant) else explain() }, + onLongClick = { if (variant != null) onLongPress(variant) else explain() }, + ) + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } + } + } + + @androidx.compose.runtime.Composable + private fun ResetButton() { + Button( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray, contentColor = Color.White), + onClick = { + lifecycleScope.launch { + val message = withContext(Dispatchers.IO) { resetDatabases() } + latestResult = message + sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } + }, + ) { + Text("Drop all tables (reset)") + } + } + + @androidx.compose.runtime.Composable + private fun DetailField(label: String, value: String, borderColor: Color) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + // The border color is driven by the shimmer animation so the box pulses on each SQL run. + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = borderColor, + unfocusedBorderColor = borderColor, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + /** + * Runs [block] inside a scope-bound transaction and returns the result. When [shareScreenTrace] + * is enabled, the transaction continues this screen's trace so all demos in one visit share a + * trace; otherwise it starts its own trace (1 transaction = 1 trace). + */ + private suspend fun runInTransaction( + transactionName: String, + op: String, + block: suspend () -> String, + ): String { + // Continuing the screen trace keeps the shared trace id but mints a fresh span id for this + // transaction; the standalone path (and the continueTrace fallback when tracing is disabled) + // gives the transaction its own trace. + val context = + if (shareScreenTrace) { + Sentry.continueTrace(screenTraceHeader, null)?.apply { + name = transactionName + operation = op + } ?: TransactionContext(transactionName, op) + } else { + TransactionContext(transactionName, op) + } + + val options = TransactionOptions().apply { isBindToScope = true } + val transaction = Sentry.startTransaction(context, options) + + return try { + val result = block() + transaction.status = SpanStatus.OK + result + } catch (t: Throwable) { + transaction.status = SpanStatus.INTERNAL_ERROR + "$transactionName failed: ${t.message}" + } finally { + transaction.finish() + } + } + + /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ + private fun resetDatabases(): String { + val cleared = SampleDatabases.reset(applicationContext) + return "Dropped tables: cleared $cleared database file(s)." + } + + private companion object { + + /** + * Builds a fresh sentry-trace header ("--") representing this screen + * visit's trace. The trailing "-1" marks it sampled so the whole session is kept. + */ + private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt new file mode 100644 index 00000000000..642bd1c902f --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -0,0 +1,214 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.room.Room +import androidx.room3.Room as Room3 +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper +import io.sentry.samples.android.sqlite.SampleDatabases.driverDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.openHelperDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.reset +import io.sentry.samples.android.sqlite.SampleDatabases.warmUp +import io.sentry.sqlite.SentrySQLiteDriver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Process-lifetime holder for the demo databases used by [SQLiteActivity]. + * + * Real apps open a database once (commonly a DI singleton) and keep it open for the process, so a + * screen that touches the DB almost always finds it already "warm". We model that here: [warmUp] is + * called from `MyApplication` at launch, off the main thread, so the one-time open + Room + * connection-pool bootstrap happens with no active transaction — those `db.sql.query` spans have + * nothing to attach to and are dropped. Every screen afterward reuses the warm handle and records + * only its statements of interest. + * + * Handles are held for the whole process: Android has no reliable "app closed" callback, and the OS + * reclaims the connections on process death, so we never close them except via [reset] (the "Drop + * all tables" button), which closes, deletes the files, and re-warms. + * + * The two "direct" handles wrap a single raw connection that isn't safe for concurrent use, so + * callers serialize their whole unit of work via [driverDirectLock] / [openHelperDirectLock]. Room + * and SQLDelight manage their own connection pools and don't need one. + */ +object SampleDatabases { + + val driverDirectLock = Any() + val openHelperDirectLock = Any() + + @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var driverRoom3Db: SampleRoom3Database? = null + @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var openHelperRoomDb: SampleRoom2Database? = null + @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null + + fun driverConnection(context: Context): SQLiteConnection = + synchronized(driverDirectLock) { + driverConnection + ?: SentrySQLiteDriver.create(BundledSQLiteDriver()) + .open(databaseFile(context, "driver_direct.db")) + .also { + it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open + driverConnection = it + } + } + + fun driverRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + driverRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "driver_room2.db", + ) + .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { driverRoom2Db = it } + } + + fun driverRoom3Db(context: Context): SampleRoom3Database = + synchronized(this) { + driverRoom3Db + ?: Room3.databaseBuilder(context.applicationContext, "driver_room3.db") + .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + .also { driverRoom3Db = it } + } + + fun directHelper(context: Context): SupportSQLiteOpenHelper = + synchronized(openHelperDirectLock) { + directHelper ?: buildDirectHelper(context).also { directHelper = it } + } + + fun openHelperRoomDb(context: Context): SampleRoom2Database = + synchronized(this) { + openHelperRoomDb + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "openhelper_room.db", + ) + .openHelperFactory { configuration -> + SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + .fallbackToDestructiveMigration(true) + .build() + .also { openHelperRoomDb = it } + } + + fun sqlDelightDriver(context: Context): AndroidSqliteDriver = + synchronized(this) { + sqlDelightDriver + ?: AndroidSqliteDriver( + schema = SampleSQLDelightDatabase.Schema, + context = context.applicationContext, + name = "openhelper_sqldelight.db", + factory = + SupportSQLiteOpenHelper.Factory { configuration -> + SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + }, + ) + .also { sqlDelightDriver = it } + } + + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name("openhelper_direct.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL(SqlStatements.CREATE_SONG) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + } + ) + .build() + return SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + + /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ + fun warmUp(context: Context) { + val appContext = context.applicationContext + // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. + CoroutineScope(Dispatchers.IO).launch { + runCatching { driverConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runCatching { driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { directHelper(appContext).writableDatabase } + runCatching { openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + } + } + + /** + * Closes the open handles, deletes every demo database file, then re-warms. Returns the number of + * files cleared. + */ + fun reset(context: Context): Int { + closeAll() + val appContext = context.applicationContext + val names = + listOf( + "driver_direct.db", + "driver_room2.db", + "driver_room3.db", + "openhelper_direct.db", + "openhelper_room.db", + "openhelper_sqldelight.db", + ) + val cleared = names.count { appContext.deleteDatabase(it) } + warmUp(appContext) + return cleared + } + + private fun closeAll() { + synchronized(driverDirectLock) { + driverConnection?.close() + driverConnection = null + } + synchronized(openHelperDirectLock) { + directHelper?.close() + directHelper = null + } + synchronized(this) { + driverRoom2Db?.close() + driverRoom2Db = null + driverRoom3Db?.close() + driverRoom3Db = null + openHelperRoomDb?.close() + openHelperRoomDb = null + sqlDelightDriver?.close() + sqlDelightDriver = null + } + } + + private fun databaseFile(context: Context, name: String): String = + context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq new file mode 100644 index 00000000000..345e55a3582 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq @@ -0,0 +1,17 @@ +CREATE TABLE song ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + artist TEXT NOT NULL +); + +insertSong: +INSERT INTO song(title, artist) +VALUES (?, ?); + +selectAll: +SELECT * +FROM song; + +countSongs: +SELECT count(*) +FROM song; diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt new file mode 100644 index 00000000000..61dd2c732a7 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -0,0 +1,224 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Rows inserted (and then consumed + processed) per demo when "heavy application-level work" is + * enabled. + */ +private const val HEAVY_ROW_COUNT = 50 + +/** + * Identifies a single SQLite demo: one of the two integrations crossed with the way it's used + * (raw/direct, Room, or SQLDelight). Used to dispatch the same SQL from both trace styles. + */ +enum class SqlDemo { + DRIVER_DIRECT, + DRIVER_ROOM2, + DRIVER_ROOM3, + OPENHELPER_DIRECT, + OPENHELPER_ROOM, + OPENHELPER_SQLDELIGHT, +} + +/** + * Executable SQL and demo runners for the SQLite sample screens. The human-readable "SQL run" + * summaries shown in the UI live in the per-demo [DisplayInfo] constants; keep those in lockstep + * with the statements here. + * + * The actual SQL each demo runs is kept separate from how its trace is created so the two screens + * can share it: + * - [SQLiteActivity]: Wraps [execute] in a manual `Sentry.startTransaction(…)`. + * - [UiLoadActivity]: Calls the same [execute] with no manual transaction, so the screen's auto + * `ui.load` transaction owns the resulting `db.sql.query` spans. + * + * All demos read the shared, already-warm handles from [SampleDatabases] and return a short status + * line. [heavy] mirrors the screen's "heavy app-level work" toggle. When enabled, each demo also + * batch inserts [HEAVY_ROW_COUNT] rows and consumes them with per-row [appWork]. + */ +object SqlStatements { + + const val CREATE_SONG = + "CREATE TABLE IF NOT EXISTS song(id INTEGER PRIMARY KEY, title TEXT, artist TEXT)" + const val INSERT_SONG = "INSERT INTO song(title, artist) VALUES (?, ?)" + const val SELECT_SONGS = "SELECT id, title, artist FROM song" + const val COUNT_SONGS = "SELECT count(*) FROM song" + + /** + * A single multi-row INSERT for [rowCount] songs, bound with [batchSongArgs]. One statement <> + * one round-trip, which is the realistic way to add a known batch of rows, rather than a loop of + * [rowCount] single-row inserts. + */ + fun insertSongsBatch(rowCount: Int): String = + "INSERT INTO song(title, artist) VALUES " + List(rowCount) { "(?, ?)" }.joinToString(", ") + + /** Flattened title/artist bind args for [insertSongsBatch]: "song 0", "artist 0", "song 1", … */ + fun batchSongArgs(rowCount: Int): Array = + Array(rowCount * 2) { i -> if (i % 2 == 0) "song ${i / 2}" else "artist ${i / 2}" } + + suspend fun execute(context: Context, demo: SqlDemo, heavy: Boolean): String = + when (demo) { + SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) + SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) + SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) + SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) + SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) + } + + // --- 1. SentrySQLiteDriver, used directly ------------------------------------------------- + + private fun driverDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.driverDirectLock) { + val connection = SampleDatabases.driverConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + // Consumption: pull each column across the JNI boundary into the ART heap. + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "Driver (Direct): ${count(connection)} rows." + } + + private fun insert(connection: SQLiteConnection, title: String, artist: String) { + connection.prepare(INSERT_SONG).use { statement -> + statement.bindText(1, title) + statement.bindText(2, artist) + statement.step() + } + } + + private fun count(connection: SQLiteConnection): Long = + connection.prepare(COUNT_SONGS).use { statement -> + if (statement.step()) statement.getLong(0) else 0 + } + + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- + + private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.driverRoom2Db(context).songDao(), "Driver (Room 2)", heavy) + + /** + * Shared Room 2 demo so the driver and open-helper paths run *identical* SQL. The only difference + * is how each integration instruments it: the driver spans every read, while the open helper's + * Room reads go via `moveToNext()` and emit no span, so only the INSERTs are spanned. + */ + private suspend fun roomDemo(dao: SongDao, label: String, heavy: Boolean): String { + dao.insert(SongEntity(title = "Spiders (Kidsmoke)", artist = "Wilco")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a per-row loop. + dao.insertAll(List(HEAVY_ROW_COUNT) { SongEntity(title = "song $it", artist = "artist $it") }) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "$label: ${dao.count()} rows." + } + + // --- 2b. SentrySQLiteDriver, used through Room 3.0+ (androidx.room3) ----------------------- + + private suspend fun driverWithRoom3(context: Context, heavy: Boolean): String { + val dao = SampleDatabases.driverRoom3Db(context).songDao() + dao.insert(SongEntity3(title = "What's Up", artist = "4 Non Blondes")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a naive per-row + // loop. + dao.insertAll( + List(HEAVY_ROW_COUNT) { SongEntity3(title = "song $it", artist = "artist $it") } + ) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "Driver (Room 3): ${dao.count()} rows." + } + + // --- 3. SentrySupportSQLiteOpenHelper, used directly -------------------------------------- + + private fun openHelperDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.openHelperDirectLock) { + // Runs the *same* SQL as driverDirect(), so the only difference you see in the Sentry UI is + // how each integration instruments identical statements. + val db = SampleDatabases.directHelper(context).writableDatabase + db.execSQL(INSERT_SONG, arrayOf("Mishima / Closing", "Philip Glass")) + db.execSQL(INSERT_SONG, arrayOf("School of Velocity, op 299 no 1, ", "Carl Czerny")) + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + db.execSQL(insertSongsBatch(HEAVY_ROW_COUNT), batchSongArgs(HEAVY_ROW_COUNT)) + db.query(SELECT_SONGS).use { cursor -> + while (cursor.moveToNext()) { + // Consumption: read each column out of the cursor window. + val row = "${cursor.getLong(0)}:${cursor.getString(1)}:${cursor.getString(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "OpenHelper (Direct): ${querySongCount(db)} rows." + } + + /** + * Runs the shared `SELECT count(*)` through the open helper and returns the value, read the + * normal way: moveToFirst() + getInt(). These are delegated straight to the underlying cursor + * (the open helper only instruments getCount()/onMove()/fillWindow()), so this read produces no + * `db.sql.query` span — the same as a real app reading a scalar count. + */ + private fun querySongCount(db: SupportSQLiteDatabase): Int = + db.query(COUNT_SONGS).use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + + // --- 4. SentrySupportSQLiteOpenHelper, used through Room ---------------------------------- + + // Runs the same [roomDemo] SQL as the driver path; only the instrumentation differs. + private suspend fun openHelperWithRoom(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.openHelperRoomDb(context).songDao(), "OpenHelper (Room)", heavy) + + // --- 5. SentrySupportSQLiteOpenHelper, used through SQLDelight ---------------------------- + + private fun openHelperWithSqlDelight(context: Context, heavy: Boolean): String { + val database = SampleSQLDelightDatabase(SampleDatabases.sqlDelightDriver(context)) + database.songQueries.insertSong("Nightcall", "Kavinsky") + if (heavy) { + // Wrap the batch in one transaction, vs. each insertSong() naively committing on its own. + database.transaction { + repeat(HEAVY_ROW_COUNT) { database.songQueries.insertSong("song $it", "artist $it") } + } + database.songQueries.selectAll().executeAsList().forEach { + appWork("${it.id}:${it.title}:${it.artist}") + } + } + // SQLDelight reads its cursor only via moveToNext(), which is delegated past the wrapper, so + // this count read produces no span. + val count = database.songQueries.countSongs().executeAsOne() + return "OpenHelper (SQLDelight): $count rows." + } + + /** + * Simulates per-row application-level work (e.g. decrypting a column) on consumed results. This + * is deliberately CPU-heavy and unrelated to the SQLite engine. + */ + private fun appWork(value: String) { + val digest = java.security.MessageDigest.getInstance("SHA-256") + var bytes = value.toByteArray() + repeat(500) { bytes = digest.digest(bytes) } + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt new file mode 100644 index 00000000000..036ba4332a0 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -0,0 +1,64 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity that lets us simulate SDK auto-generation of a `ui.load` transaction + attach SQLite + * statement spans to it. + * + * Timing note: the work runs off the main thread, so it finishes after the screen is first drawn. + * Time-to-full-display tracing (enabled in the manifest) keeps the `ui.load` transaction open until + * [Sentry.reportFullyDisplayed], which we call once the work completes — otherwise the transaction + * would auto-finish at first display and the late db spans would have nowhere to attach. + */ +class UiLoadActivity : ComponentActivity() { + + private var status by mutableStateOf("Running under the screen's auto ui.load transaction…") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val id = SqlDemo.valueOf(intent.getStringExtra(EXTRA_DEMO_ID).orEmpty()) + val heavy = intent.getBooleanExtra(EXTRA_HEAVY, false) + + setContent { UiLoadScreen(status = status, onClose = ::finish) } + + // No Sentry.startTransaction(): the work runs under the auto ui.load:UiLoadActivity span. + lifecycleScope.launch { + status = + try { + val result = + withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } + "$result\n\nRan under the auto ui.load transaction." + } catch (t: Throwable) { + "Load failed: ${t.message}" + } finally { + // Close the TTFD window so the ui.load transaction finishes with the db spans attached. + Sentry.reportFullyDisplayed() + } + } + } + + companion object { + private const val EXTRA_DEMO_ID = "demo_id" + private const val EXTRA_HEAVY = "heavy" + + /** Builds the intent that runs [id] (honoring the [heavy] toggle) on this UiLoadScreen. */ + fun intent(context: Context, id: SqlDemo, heavy: Boolean): Intent = + Intent(context, UiLoadActivity::class.java) + .putExtra(EXTRA_DEMO_ID, id.name) + .putExtra(EXTRA_HEAVY, heavy) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt new file mode 100644 index 00000000000..6495726448d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt @@ -0,0 +1,110 @@ +package io.sentry.samples.android.sqlite + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.sentry.samples.android.R + +private val ShimmerHighlight = Color(0xFFBDBDBD) + +@Composable +fun UiLoadScreen(status: String, onClose: () -> Unit) { + MaterialTheme { + Surface { + Box( + modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().padding(24.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center).fillMaxWidth().offset(y = (-48).dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShimmerSentryGlyph(modifier = Modifier.size(96.dp)) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = status, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = onClose, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + colors = + ButtonDefaults.buttonColors(containerColor = Color.Black, contentColor = Color.White), + ) { + Text("Close") + } + } + } + } +} + +@Composable +private fun ShimmerSentryGlyph(modifier: Modifier = Modifier) { + val progress = remember { Animatable(0f) } + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 700, delayMillis = 250, easing = LinearEasing), + ) + } + + Image( + painter = painterResource(R.drawable.sentry_glyph), + contentDescription = "Sentry", + modifier = + modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val p = progress.value + val band = size.width * 0.5f + // Sweep the highlight band diagonally from off the bottom-left corner (p=0) to off the + // top-right corner (p=1): x travels left→right, y travels bottom→top. + val x = -band + (size.width + 2f * band) * p + val y = (size.height + band) - (size.height + 2f * band) * p + drawRect( + brush = + Brush.linearGradient( + colors = listOf(Color.Black, ShimmerHighlight, Color.Black), + start = Offset(x, y), + end = Offset(x + band, y - band), + ), + blendMode = BlendMode.SrcAtop, + ) + }, + ) +} From 0b1a52ed3759a21582d4280d527a3e01ce8fb99e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 5 Jun 2026 14:43:59 +0200 Subject: [PATCH 07/15] Prevent db reset while statement is in flight; warm up Room3 db upon app start --- .../sentry/samples/android/sqlite/Room3Dao.kt | 7 +++ .../samples/android/sqlite/SQLiteActivity.kt | 60 +++++++++++++++---- .../samples/android/sqlite/SampleDatabases.kt | 1 + 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt index 2a7e3575179..145e12d3897 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt @@ -26,6 +26,13 @@ interface SongDao3 { @Query("SELECT * FROM song") suspend fun getAll(): List @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() } @Database(entities = [SongEntity3::class], version = 1, exportSchema = false) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index 9e5c4f4f87b..f0492c0b47a 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -204,6 +204,12 @@ class SQLiteActivity : ComponentActivity() { /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ private var runTick by mutableStateOf(0) + /** True while a demo or reset is running SQL on a background thread. */ + private var dbOperationInFlight by mutableStateOf(false) + + /** True for the duration of a reset; disables the reset button immediately (no debounce). */ + private var resetInProgress by mutableStateOf(false) + /** * The shared trace used when [shareScreenTrace] is enabled: one trace per visit to this screen. * onResume() generates a fresh one each time the screen is (re)entered. @@ -316,7 +322,10 @@ class SQLiteActivity : ComponentActivity() { ) } - ResetButton() + ResetButton( + dbOperationInFlight = dbOperationInFlight, + resetInProgress = resetInProgress, + ) // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from // the detail output. @@ -348,12 +357,17 @@ class SQLiteActivity : ComponentActivity() { runTick++ // shimmer the detail box outline in the integration color lifecycleScope.launch { - latestResult = - withContext(Dispatchers.IO) { - runInTransaction(variant.transactionName, variant.op) { - SqlStatements.execute(applicationContext, variant.demo, heavyWork) + dbOperationInFlight = true + try { + latestResult = + withContext(Dispatchers.IO) { + runInTransaction(variant.transactionName, variant.op) { + SqlStatements.execute(applicationContext, variant.demo, heavyWork) + } } - } + } finally { + dbOperationInFlight = false + } } } @@ -488,15 +502,38 @@ class SQLiteActivity : ComponentActivity() { } @androidx.compose.runtime.Composable - private fun ResetButton() { + private fun ResetButton(dbOperationInFlight: Boolean, resetInProgress: Boolean) { + // Debounce demo-driven disablement so fast taps don't flicker the button; reset disables + // immediately via [resetInProgress]. [dbOperationInFlight] still guards [onClick] either way. + var enabled by remember { mutableStateOf(true) } + LaunchedEffect(dbOperationInFlight, resetInProgress) { + when { + resetInProgress -> enabled = false + dbOperationInFlight -> { + delay(RESET_DISABLE_DEBOUNCE_MS) + enabled = false + } + else -> enabled = true + } + } + Button( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + enabled = enabled, colors = ButtonDefaults.buttonColors(containerColor = Color.Gray, contentColor = Color.White), onClick = { + if (dbOperationInFlight) return@Button lifecycleScope.launch { - val message = withContext(Dispatchers.IO) { resetDatabases() } - latestResult = message - sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + this@SQLiteActivity.resetInProgress = true + this@SQLiteActivity.dbOperationInFlight = true + try { + val message = withContext(Dispatchers.IO) { resetDatabases() } + latestResult = message + sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } finally { + this@SQLiteActivity.dbOperationInFlight = false + this@SQLiteActivity.resetInProgress = false + } } }, ) { @@ -568,6 +605,9 @@ class SQLiteActivity : ComponentActivity() { private companion object { + /** Demo SQL shorter than this won't visibly disable the reset button. */ + private const val RESET_DISABLE_DEBOUNCE_MS = 300L + /** * Builds a fresh sentry-trace header ("--") representing this screen * visit's trace. The trailing "-1" marks it sampled so the whole session is kept. diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 642bd1c902f..8479a5c1eb2 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -156,6 +156,7 @@ object SampleDatabases { // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its // transaction. runCatching { driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() } runCatching { directHelper(appContext).writableDatabase } runCatching { openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() } runCatching { From 8b3c175fd2cd985623cde0389deda58a00e978ac Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 8 Jun 2026 09:17:28 +0200 Subject: [PATCH 08/15] Serialize demo SQL and block concurrent taps --- .../samples/android/sqlite/SQLiteActivity.kt | 6 +++++- .../samples/android/sqlite/SampleDatabases.kt | 13 ++++++++++--- .../samples/android/sqlite/SqlStatements.kt | 16 +++++++++------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index f0492c0b47a..1ff6828a757 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -353,6 +353,8 @@ class SQLiteActivity : ComponentActivity() { /** Run the variant's SQL statement inside a manual, scope-bound transaction. */ private fun onTap(variant: DemoVariant) { + if (dbOperationInFlight) return + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql runTick++ // shimmer the detail box outline in the integration color @@ -376,6 +378,8 @@ class SQLiteActivity : ComponentActivity() { * `ui.load` transaction owns the spans. */ private fun onLongPress(variant: DemoVariant) { + if (dbOperationInFlight) return + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql latestResult = "Opened the auto-load screen — its ui.load transaction owns the db spans." startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) @@ -598,7 +602,7 @@ class SQLiteActivity : ComponentActivity() { } /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ - private fun resetDatabases(): String { + private suspend fun resetDatabases(): String { val cleared = SampleDatabases.reset(applicationContext) return "Dropped tables: cleared $cleared database file(s)." } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 8479a5c1eb2..63f217fcfbb 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -19,6 +19,8 @@ import io.sentry.sqlite.SentrySQLiteDriver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Process-lifetime holder for the demo databases used by [SQLiteActivity]. @@ -40,9 +42,14 @@ import kotlinx.coroutines.launch */ object SampleDatabases { + private val sqlAccess = Mutex() + val driverDirectLock = Any() val openHelperDirectLock = Any() + /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ + suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } + @Volatile private var driverConnection: SQLiteConnection? = null @Volatile private var driverRoom2Db: SampleRoom2Database? = null @Volatile private var driverRoom3Db: SampleRoom3Database? = null @@ -170,9 +177,9 @@ object SampleDatabases { /** * Closes the open handles, deletes every demo database file, then re-warms. Returns the number of - * files cleared. + * files cleared. Waits for any in-flight demo SQL (including [UiLoadActivity]) to finish first. */ - fun reset(context: Context): Int { + suspend fun reset(context: Context): Int = withSqlAccess { closeAll() val appContext = context.applicationContext val names = @@ -186,7 +193,7 @@ object SampleDatabases { ) val cleared = names.count { appContext.deleteDatabase(it) } warmUp(appContext) - return cleared + cleared } private fun closeAll() { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt index 61dd2c732a7..543f1169294 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -59,13 +59,15 @@ object SqlStatements { Array(rowCount * 2) { i -> if (i % 2 == 0) "song ${i / 2}" else "artist ${i / 2}" } suspend fun execute(context: Context, demo: SqlDemo, heavy: Boolean): String = - when (demo) { - SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) - SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) - SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) - SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) - SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) - SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) + SampleDatabases.withSqlAccess { + when (demo) { + SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) + SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) + SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) + SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) + SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) + } } // --- 1. SentrySQLiteDriver, used directly ------------------------------------------------- From a16a9dad23f77da18441e172dbe304a871ac07f2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 8 Jun 2026 09:21:38 +0200 Subject: [PATCH 09/15] Guard UiLoadActivity against missing demo extra --- .../io/sentry/samples/android/sqlite/UiLoadActivity.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt index 036ba4332a0..b32811e8c91 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -30,7 +30,12 @@ class UiLoadActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val id = SqlDemo.valueOf(intent.getStringExtra(EXTRA_DEMO_ID).orEmpty()) + val id = + SqlDemo.entries.find { it.name == intent.getStringExtra(EXTRA_DEMO_ID) } + ?: run { + finish() + return + } val heavy = intent.getBooleanExtra(EXTRA_HEAVY, false) setContent { UiLoadScreen(status = status, onClose = ::finish) } From 5ae931e15b116b312e3642da2df6979d7d79492a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 6 Jun 2026 10:26:59 +0200 Subject: [PATCH 10/15] chore(android-sqlite): Skip wrapping SupportSQLiteDriver bridge to avoid duplicate spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SentrySQLiteDriver.create() now recognizes the Room 2.7+ androidx.sqlite.driver.SupportSQLiteDriver bridge adapter and returns it unwrapped. That lets us protect against the one known vector where using both SentrySQLiteDriver and SentrySupportSQLiteOpenHelper with the same db table is allowed under either the Room or SQLDelight APIs: ```kotlin // AVOID — this configuration produces duplicate spans for every SQL statement. // Step 1: Developer wraps their open helper with Sentry, either manually or // via the Sentry Android Gradle Plugin. val sentryWrappedHelper: SupportSQLiteOpenHelper = SentrySupportSQLiteOpenHelper.create( FrameworkSQLiteOpenHelperFactory().create(configuration) ) // Step 2: Developer builds the compat driver around that wrapped helper. val driver: SQLiteDriver = SupportSQLiteDriver(sentryWrappedHelper) // Step 3: Developer (wrongly!) wraps the driver with Sentry as well. All // spans will now be duplicated. val sentryWrappedDriver: SQLiteDriver = SentrySQLiteDriver.create(driver) Room.databaseBuilder(context, MyDb::class.java, "mydb") .setDriver(sentryWrappedDriver) .build() ``` This commit lets us avoid step 3 by no-op'ing if a developer tries to pass a SupportSQLiteDriver to SentrySQLiteDriver.create(). --- CHANGELOG.md | 1 + .../io/sentry/sqlite/SentrySQLiteDriver.kt | 23 +++++++++++++++---- .../sqlite/driver/SupportSQLiteDriver.kt | 22 ++++++++++++++++++ .../sentry/sqlite/SentrySQLiteDriverTest.kt | 11 +++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b7e2a85bd..dd8fc49b3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Automatically generates spans for all SQLite statements - To use it, pass your `SQLiteDriver` to `SentrySQLiteDriver.create(...)` - You'll need `androidx.sqlite:sqlite` (2.5.0+) on your app's classpath (Room usually provides it for you). androidx.sqlite 2.6.0+ requires minSdk 23. + - The Room 2.7+ `androidx.sqlite.driver.SupportSQLiteDriver` bridge adapter is recognized and skipped by `SentrySQLiteDriver.create(...)` so apps that wrap both the open helper and the bridge driver do not emit duplicate spans. Spans come from the open helper layer in that configuration. - See https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/ for more details, including info about migrating from `SentrySupportSQLiteOpenHelper` ## 8.43.1 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 7c7e24ac075..44e29605b34 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 @@ -21,10 +21,9 @@ import io.sentry.SentryLevel * .build() * ``` * - * **Warning:** Do not use [SentrySQLiteDriver] together with - * [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. + * Note: In order to avoid duplicate spans, wrapping no-ops in the case of the + * `androidx.sqlite.driver.SupportSQLiteDriver`. Wrap the open helper passed to its constructor via + * `SentrySupportSQLiteOpenHelper` instead. * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ @@ -68,8 +67,22 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite public companion object { + /** + * Fully-qualified class name of the bridge adapter often used with Room 2.7+. It implements the + * `SQLiteDriver` interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of + * the Sentry Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them + * automatically.) We deliberately avoid wrapping the adapter to prevent duplicate spans. + */ + private const val SUPPORT_SQLITE_DRIVER_FQN = "androidx.sqlite.driver.SupportSQLiteDriver" + @JvmStatic public fun create(delegate: SQLiteDriver): SQLiteDriver = - delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) + // String rather than an `is` check for SupportSQLiteDriver to avoid a compile-time dependency + // on androidx.sqlite:sqlite-framework. + if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { + delegate + } else { + SentrySQLiteDriver(delegate) + } } } diff --git a/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt new file mode 100644 index 00000000000..013b078239a --- /dev/null +++ b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt @@ -0,0 +1,22 @@ +package androidx.sqlite.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver + +/** + * Minimal stub of `androidx.sqlite.driver.SupportSQLiteDriver` (which lives in + * `androidx.sqlite:sqlite-framework`, not on this module's compile/test classpath) for verifying + * behavior of `SentrySQLiteDriver.create(SupportSQLiteDriver)`. + * + * The production check is `delegate.javaClass.name == + * "androidx.sqlite.driver.SupportSQLiteDriver"`, so any class with this exact fully-qualified name + * exercises the branch. + */ +internal class SupportSQLiteDriver : SQLiteDriver { + + override val hasConnectionPool: Boolean = false + + override fun open(fileName: String): SQLiteConnection { + throw UnsupportedOperationException("Test stub; not for runtime use") + } +} 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 index 9b2345a975f..5816f3d859c 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -3,6 +3,7 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import androidx.sqlite.SQLiteStatement +import androidx.sqlite.driver.SupportSQLiteDriver import io.sentry.IScopes import io.sentry.Sentry import io.sentry.SentryIntegrationPackageStorage @@ -64,6 +65,16 @@ class SentrySQLiteDriverTest { assertSame(wrapped, doubleWrapped) } + @Test + fun `create with SupportSQLiteDriver bridge returns same instance without wrapping`() { + val bridge = SupportSQLiteDriver() + + val result = SentrySQLiteDriver.create(bridge) + + assertSame(bridge, result) + assertFalse(result is SentrySQLiteDriver) + } + @Test fun `hasConnectionPool forwards delegate value when supported`() { whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) From 804f413b5c417eedea63149872cc0507398e6768 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 8 Jun 2026 09:53:46 +0200 Subject: [PATCH 11/15] chore(android-sqlite): Add SupportSQLiteDriver bridge mode to Android sample app to exercise duplicate-span guard Replace the two-way integration switch with a three-way segmented control and manually construct the SupportSQLiteDriver stack so reviewers can confirm a single helper-layer span per statement when users access their db files through the SupportSQLiteDriver API. --- .../io/sentry/sqlite/SentrySQLiteDriver.kt | 11 +- .../sqlite/driver/SupportSQLiteDriver.kt | 4 - .../samples/android/sqlite/DisplayInfo.kt | 5 + .../samples/android/sqlite/SQLiteActivity.kt | 183 ++++++++++++++---- .../samples/android/sqlite/SampleDatabases.kt | 177 +++++++++++++++-- .../samples/android/sqlite/SqlStatements.kt | 35 ++++ .../samples/android/sqlite/UiLoadActivity.kt | 5 +- 7 files changed, 357 insertions(+), 63 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 44e29605b34..2c43a344227 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 @@ -68,17 +68,18 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite public companion object { /** - * Fully-qualified class name of the bridge adapter often used with Room 2.7+. It implements the - * `SQLiteDriver` interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of - * the Sentry Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them + * Name of the bridge adapter often used with Room 2.7+. It implements the `SQLiteDriver` + * interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of the Sentry + * Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them * automatically.) We deliberately avoid wrapping the adapter to prevent duplicate spans. + * + * String (rather than an `is` check) lets us avoid a compile-time dependency on + * androidx.sqlite:sqlite-framework. */ private const val SUPPORT_SQLITE_DRIVER_FQN = "androidx.sqlite.driver.SupportSQLiteDriver" @JvmStatic public fun create(delegate: SQLiteDriver): SQLiteDriver = - // String rather than an `is` check for SupportSQLiteDriver to avoid a compile-time dependency - // on androidx.sqlite:sqlite-framework. if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { delegate } else { diff --git a/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt index 013b078239a..2de7f1d38f5 100644 --- a/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt +++ b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt @@ -7,10 +7,6 @@ import androidx.sqlite.SQLiteDriver * Minimal stub of `androidx.sqlite.driver.SupportSQLiteDriver` (which lives in * `androidx.sqlite:sqlite-framework`, not on this module's compile/test classpath) for verifying * behavior of `SentrySQLiteDriver.create(SupportSQLiteDriver)`. - * - * The production check is `delegate.javaClass.name == - * "androidx.sqlite.driver.SupportSQLiteDriver"`, so any class with this exact fully-qualified name - * exercises the branch. */ internal class SupportSQLiteDriver : SQLiteDriver { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt index 14582fe305e..fd80a5aae1e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt @@ -87,6 +87,11 @@ internal val OPENHELPER_ROOM = .trimIndent(), ) +// Bridge demos run the same SQL as the driver paths; spans come from the open-helper layer. +internal val BRIDGE_DIRECT = DRIVER_DIRECT + +internal val BRIDGE_ROOM2 = DRIVER_ROOM2 + internal val OPENHELPER_SQLDELIGHT = DisplayInfo( sql = diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index 1ff6828a757..9454002f982 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -1,6 +1,7 @@ package io.sentry.samples.android.sqlite import android.os.Bundle +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -33,6 +34,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchColors @@ -73,6 +77,7 @@ import kotlinx.coroutines.withContext private val SentryPink = Color(0xFFC85B9C) private val SentryPurple = Color(0xFF7B52FB) +private val SentryOrange = Color(0xFFE8743F) private val SentryRed = Color(0xFFF55459) /** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ @@ -88,10 +93,33 @@ private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 private val SECTION_HEADER_HEIGHT = 28.dp -/** Which sentry-android-sqlite integration the demo buttons currently target. */ -private enum class Integration(val color: Color, val apiName: String) { - DRIVER(SentryPurple, "SQLiteDriver"), - OPEN_HELPER(SentryPink, "SupportSQLiteOpenHelper"), +/** Which sentry-android-sqlite integration the demo currently targets. */ +private enum class IntegrationMode( + val color: Color, + val segmentLabel: String, + val apiName: String, + val subtitle: String, +) { + DRIVER( + SentryPurple, + "SQLiteDriver", + "SQLiteDriver", + "SentrySQLiteDriver.create(BundledSQLiteDriver)", + ), + OPEN_HELPER( + SentryPink, + "OpenHelper", + "SupportSQLiteOpenHelper", + "SentrySupportSQLiteOpenHelper.create(...)", + ), + // Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER + // integrations are used together via the SupportSQLiteDriver bridge. + BRIDGE( + SentryOrange, + "Bridge", + "SupportSQLiteDriver bridge", + "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))", + ), } /** @@ -107,11 +135,24 @@ private class DemoVariant( ) /** - * A single demo button in the list. [driver] / [openHelper] hold the variant for each integration; - * a null variant means the row doesn't apply to that integration and renders dimmed, explaining why - * on click (Room 3 is driver-only; SQLDelight is open-helper-only). + * A single demo button in the list. [driver] / [openHelper] / [bridge] hold the variant for each + * integration; a null variant means the row doesn't apply and renders dimmed (e.g., Room 3 is + * driver-only; SQLDelight is open-helper-only; etc.). */ -private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?) +private class DemoRow( + val label: String, + val driver: DemoVariant?, + val openHelper: DemoVariant?, + val bridge: DemoVariant?, +) { + + fun variantFor(mode: IntegrationMode): DemoVariant? = + when (mode) { + IntegrationMode.DRIVER -> driver + IntegrationMode.OPEN_HELPER -> openHelper + IntegrationMode.BRIDGE -> bridge + } +} // The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual // SQL lives in SqlStatements, dispatched by id. @@ -133,6 +174,13 @@ private val DEMO_ROWS = op = "db.sql.openhelper-direct", displayInfo = OPENHELPER_DIRECT, ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_DIRECT, + transactionName = "Bridge stack — Direct", + op = "db.sql.bridge-direct", + displayInfo = BRIDGE_DIRECT, + ), ), DemoRow( label = "Room 2", @@ -150,6 +198,13 @@ private val DEMO_ROWS = op = "db.sql.openhelper-room", displayInfo = OPENHELPER_ROOM, ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_ROOM2, + transactionName = "Bridge stack — Room 2", + op = "db.sql.bridge-room2", + displayInfo = BRIDGE_ROOM2, + ), ), DemoRow( label = "Room 3", @@ -161,6 +216,7 @@ private val DEMO_ROWS = displayInfo = DRIVER_ROOM3, ), openHelper = null, // Room 3 only runs on the SQLiteDriver path. + bridge = null, ), DemoRow( label = "SQLDelight", @@ -172,6 +228,7 @@ private val DEMO_ROWS = op = "db.sql.openhelper-sqldelight", displayInfo = OPENHELPER_SQLDELIGHT, ), + bridge = null, ), ) @@ -187,6 +244,7 @@ private val DEMO_ROWS = class SQLiteActivity : ComponentActivity() { private var latestResult by mutableStateOf("") + private var warmUpErrors by mutableStateOf("") private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) private var heavyWork by mutableStateOf(false) @@ -198,8 +256,8 @@ class SQLiteActivity : ComponentActivity() { */ private var shareScreenTrace by mutableStateOf(false) - /** Which integration the demo buttons target. Switching it disables the rows that don't apply. */ - private var integration by mutableStateOf(Integration.DRIVER) + /** Which integration is currently being demoed. Switching it disables rows that don't apply. */ + private var integration by mutableStateOf(IntegrationMode.DRIVER) /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ private var runTick by mutableStateOf(0) @@ -265,31 +323,19 @@ class SQLiteActivity : ComponentActivity() { SectionHeader("Configure it") - val openHelper = integration == Integration.OPEN_HELPER - val integrationSwitchColors = - SwitchDefaults.colors( - checkedTrackColor = SentryPink, - checkedBorderColor = SentryPink, - uncheckedTrackColor = SentryPurple, - uncheckedBorderColor = SentryPurple, - uncheckedThumbColor = Color.White, - ) val controlSwitchColors = SwitchDefaults.colors( checkedTrackColor = Color.Black, checkedBorderColor = Color.Black, ) - ToggleRow( - label = if (openHelper) "SentrySupportSQLiteOpenHelper" else "SentrySQLiteDriver", - checked = openHelper, - labelColor = if (openHelper) SentryPink else SentryPurple, - switchColors = integrationSwitchColors, - ) { - integration = if (it) Integration.OPEN_HELPER else Integration.DRIVER - // Switching integration starts a fresh comparison: clear the detail box and result. - sqlDetail = SQL_DETAIL_HINT - latestResult = "" - } + IntegrationModeSelector( + selected = integration, + onSelected = { + integration = it + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + }, + ) ToggleRow( label = if (heavyWork) "Heavy app-level work" else "No app-level work", checked = heavyWork, @@ -313,12 +359,12 @@ class SQLiteActivity : ComponentActivity() { // integration's variant; a row that doesn't apply explains why via a toast (see // [DemoRowButton]). DEMO_ROWS.forEach { row -> - val variant = if (integration == Integration.DRIVER) row.driver else row.openHelper + val variant = row.variantFor(integration) DemoRowButton( label = row.label, color = integration.color, variant = variant, - disabledReason = "${row.label} doesn't use the ${integration.apiName}", + disabledReason = "${row.label} doesn't apply to the ${integration.apiName} stack", ) } @@ -330,12 +376,26 @@ class SQLiteActivity : ComponentActivity() { // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from // the detail output. SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + LaunchedEffect(Unit) { + while (!SampleDatabases.isWarmUpComplete()) { + warmUpErrors = SampleDatabases.warmUpErrors + delay(250) + } + warmUpErrors = SampleDatabases.warmUpErrors + } + if (warmUpErrors.isNotEmpty()) { + Text( + text = warmUpErrors, + style = MaterialTheme.typography.bodyMedium, + color = SentryRed, + ) + } // The latest run result (row counts, errors). Hidden until the first run. if (latestResult.isNotEmpty()) { Text( text = latestResult, style = MaterialTheme.typography.bodyMedium, - color = if (latestResult.contains("failed")) SentryRed else Color.Unspecified, + color = if (latestResult.looksLikeError()) SentryRed else Color.Unspecified, ) } DetailField("SQL run", sqlDetail, borderColor = detailOutline) @@ -361,12 +421,13 @@ class SQLiteActivity : ComponentActivity() { lifecycleScope.launch { dbOperationInFlight = true try { - latestResult = + val result = withContext(Dispatchers.IO) { runInTransaction(variant.transactionName, variant.op) { SqlStatements.execute(applicationContext, variant.demo, heavyWork) } } + latestResult = result } finally { dbOperationInFlight = false } @@ -385,9 +446,41 @@ class SQLiteActivity : ComponentActivity() { startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) } + @OptIn(ExperimentalMaterial3Api::class) + @androidx.compose.runtime.Composable + private fun IntegrationModeSelector( + selected: IntegrationMode, + onSelected: (IntegrationMode) -> Unit, + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + IntegrationMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + shape = + SegmentedButtonDefaults.itemShape(index = index, count = IntegrationMode.entries.size), + onClick = { onSelected(mode) }, + selected = selected == mode, + icon = {}, + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = mode.color, + activeContentColor = Color.White, + ), + label = { Text(mode.segmentLabel, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + Text( + text = selected.subtitle, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(top = 6.dp), + ) + } + /** * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the - * label inherits the default text color; the integration toggle passes its pink/purple instead. + * label inherits the default text color. */ @androidx.compose.runtime.Composable private fun ToggleRow( @@ -533,7 +626,11 @@ class SQLiteActivity : ComponentActivity() { try { val message = withContext(Dispatchers.IO) { resetDatabases() } latestResult = message + warmUpErrors = SampleDatabases.warmUpErrors sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } catch (t: Throwable) { + Log.e(TAG, "Reset failed", t) + latestResult = "Reset failed: ${t.message ?: t.javaClass.simpleName}" } finally { this@SQLiteActivity.dbOperationInFlight = false this@SQLiteActivity.resetInProgress = false @@ -595,7 +692,8 @@ class SQLiteActivity : ComponentActivity() { result } catch (t: Throwable) { transaction.status = SpanStatus.INTERNAL_ERROR - "$transactionName failed: ${t.message}" + Log.e(TAG, "$transactionName failed", t) + "$transactionName failed: ${t.message ?: t.javaClass.simpleName}" } finally { transaction.finish() } @@ -604,11 +702,20 @@ class SQLiteActivity : ComponentActivity() { /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ private suspend fun resetDatabases(): String { val cleared = SampleDatabases.reset(applicationContext) - return "Dropped tables: cleared $cleared database file(s)." + SampleDatabases.awaitWarmUp() + return buildString { + append("Dropped tables: cleared $cleared database file(s).") + if (SampleDatabases.warmUpErrors.isNotEmpty()) { + append("\n\n") + append(SampleDatabases.warmUpErrors) + } + } } private companion object { + private const val TAG = "SQLiteActivity" + /** Demo SQL shorter than this won't visibly disable the reset button. */ private const val RESET_DISABLE_DEBOUNCE_MS = 300L @@ -619,3 +726,5 @@ class SQLiteActivity : ComponentActivity() { private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" } } + +private fun String.looksLikeError(): Boolean = contains("failed", ignoreCase = true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 63f217fcfbb..62febaabef6 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -1,12 +1,14 @@ package io.sentry.samples.android.sqlite import android.content.Context +import android.util.Log import androidx.room.Room import androidx.room3.Room as Room3 import androidx.sqlite.SQLiteConnection import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import app.cash.sqldelight.driver.android.AndroidSqliteDriver @@ -18,6 +20,7 @@ import io.sentry.samples.android.sqlite.SampleDatabases.warmUp import io.sentry.sqlite.SentrySQLiteDriver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -42,18 +45,39 @@ import kotlinx.coroutines.sync.withLock */ object SampleDatabases { + private const val TAG = "SampleDatabases" + + /** Non-empty when one or more warm-up steps failed; shown on [SQLiteActivity]. */ + @Volatile + var warmUpErrors: String = "" + private set + + @Volatile private var warmUpComplete = false + @Volatile private var warmUpJob: Job? = null + + fun isWarmUpComplete(): Boolean = warmUpComplete + + /** Blocks until the in-flight [warmUp] job (if any) finishes. */ + suspend fun awaitWarmUp() { + warmUpJob?.join() + } + private val sqlAccess = Mutex() val driverDirectLock = Any() + val bridgeDirectLock = Any() val openHelperDirectLock = Any() /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var bridgeConnection: SQLiteConnection? = null @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var bridgeRoom2Db: SampleRoom2Database? = null @Volatile private var driverRoom3Db: SampleRoom3Database? = null @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var bridgeDirectHelper: SupportSQLiteOpenHelper? = null @Volatile private var openHelperRoomDb: SampleRoom2Database? = null @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null @@ -68,6 +92,45 @@ object SampleDatabases { } } + /** + * The Room 2.7+ duplicate-span scenario: a Sentry-wrapped open helper bridged to + * [SupportSQLiteDriver], then passed to [SentrySQLiteDriver.create] (which no-ops on the bridge). + */ + fun bridgeConnection(context: Context): SQLiteConnection = + synchronized(bridgeDirectLock) { + bridgeConnection + ?: run { + // SupportSQLiteDriver.open() requires fileName to match the helper's databaseName(); + // use the absolute path Room and the direct driver path both pass to open(). + val dbPath = databaseFile(context, "bridge_direct.db") + SentrySQLiteDriver.create(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) + .open(dbPath) + .also { + it.execSQL(SqlStatements.CREATE_SONG) + bridgeConnection = it + } + } + } + + fun bridgeRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + bridgeRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "bridge_room2.db", + ) + .setDriver( + SentrySQLiteDriver.create( + SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext)) + ) + ) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { bridgeRoom2Db = it } + } + fun driverRoom2Db(context: Context): SampleRoom2Database = synchronized(this) { driverRoom2Db @@ -133,10 +196,50 @@ object SampleDatabases { .also { sqlDelightDriver = it } } - private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper { + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper = + buildSentryHelper(context, "openhelper_direct.db").also { directHelper = it } + + private fun buildBridgeDirectHelper(context: Context, dbPath: String): SupportSQLiteOpenHelper = + buildSentryHelper(context, dbPath).also { bridgeDirectHelper = it } + + /** + * Open helper for the Bridge + Room 2 stack. Must not create tables in [onCreate] — Room owns the + * schema when [setDriver] is used. Room also passes [SupportSQLiteOpenHelper.databaseName] (the + * short name below), not an absolute path, to [SupportSQLiteDriver.open]. + * + * The callback version must be 1 (FrameworkSQLiteOpenHelper rejects < 1). That sets `PRAGMA + * user_version = 1` before Room opens, so Room would skip [onCreate] and validate the empty file + * as pre-packaged → "invalid schema". [onOpen] clears user_version back to 0 until + * [ROOM_MASTER_TABLE] exists. + */ + private fun buildBridgeRoom2Helper(context: Context): SupportSQLiteOpenHelper { val configuration = SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) - .name("openhelper_direct.db") + .name("bridge_room2.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + + override fun onOpen(db: SupportSQLiteDatabase) { + if (!db.hasRoomMasterTable()) { + db.execSQL("PRAGMA user_version = 0") + } + } + } + ) + .build() + return SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + + private fun buildSentryHelper(context: Context, dbName: String): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name(dbName) .callback( object : SupportSQLiteOpenHelper.Callback(1) { override fun onCreate(db: SupportSQLiteDatabase) { @@ -156,22 +259,47 @@ object SampleDatabases { /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ fun warmUp(context: Context) { val appContext = context.applicationContext + warmUpComplete = false + warmUpErrors = "" // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. - CoroutineScope(Dispatchers.IO).launch { - runCatching { driverConnection(appContext) } - // primeWriter() + count() opens both Room pool connections (writer + reader), so the first - // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its - // transaction. - runCatching { driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { directHelper(appContext).writableDatabase } - runCatching { openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { - SampleSQLDelightDatabase(sqlDelightDriver(appContext)) - .songQueries - .countSongs() - .executeAsOne() + warmUpJob = + CoroutineScope(Dispatchers.IO).launch { + val failures = mutableListOf() + runWarmUpStep("driver direct", failures) { driverConnection(appContext) } + runWarmUpStep("bridge direct", failures) { bridgeConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runWarmUpStep("driver Room 2", failures) { + driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("bridge Room 2", failures) { + bridgeRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("driver Room 3", failures) { + driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("open helper direct", failures) { directHelper(appContext).writableDatabase } + runWarmUpStep("open helper Room", failures) { + openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("SQLDelight", failures) { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + warmUpErrors = failures.joinToString("\n") { "Warm-up failed: $it" } + warmUpComplete = true } + } + + private inline fun runWarmUpStep(step: String, failures: MutableList, block: () -> Unit) { + try { + block() + } catch (t: Throwable) { + Log.e(TAG, "Warm-up failed: $step", t) + failures.add("$step: ${t.message ?: t.javaClass.simpleName}") } } @@ -185,7 +313,9 @@ object SampleDatabases { val names = listOf( "driver_direct.db", + "bridge_direct.db", "driver_room2.db", + "bridge_room2.db", "driver_room3.db", "openhelper_direct.db", "openhelper_room.db", @@ -201,6 +331,12 @@ object SampleDatabases { driverConnection?.close() driverConnection = null } + synchronized(bridgeDirectLock) { + bridgeConnection?.close() + bridgeConnection = null + bridgeDirectHelper?.close() + bridgeDirectHelper = null + } synchronized(openHelperDirectLock) { directHelper?.close() directHelper = null @@ -208,6 +344,8 @@ object SampleDatabases { synchronized(this) { driverRoom2Db?.close() driverRoom2Db = null + bridgeRoom2Db?.close() + bridgeRoom2Db = null driverRoom3Db?.close() driverRoom3Db = null openHelperRoomDb?.close() @@ -219,4 +357,11 @@ object SampleDatabases { private fun databaseFile(context: Context, name: String): String = context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath + + private fun SupportSQLiteDatabase.hasRoomMasterTable(): Boolean = + query("SELECT 1 FROM sqlite_master WHERE name = '$ROOM_MASTER_TABLE' LIMIT 1").use { + it.moveToFirst() + } } + +private const val ROOM_MASTER_TABLE = "room_master_table" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt index 543f1169294..a3646414bb7 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -18,6 +18,8 @@ enum class SqlDemo { DRIVER_DIRECT, DRIVER_ROOM2, DRIVER_ROOM3, + BRIDGE_DIRECT, + BRIDGE_ROOM2, OPENHELPER_DIRECT, OPENHELPER_ROOM, OPENHELPER_SQLDELIGHT, @@ -64,6 +66,8 @@ object SqlStatements { SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.BRIDGE_DIRECT -> bridgeDirect(context, heavy) + SqlDemo.BRIDGE_ROOM2 -> bridgeWithRoom2(context, heavy) SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) @@ -135,6 +139,37 @@ object SqlStatements { return "$label: ${dao.count()} rows." } + // --- 1b. SupportSQLiteDriver bridge (helper + driver both wrapped; SDK skips driver wrap) -- + + private fun bridgeDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.bridgeDirectLock) { + val connection = SampleDatabases.bridgeConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + appWork(row) + } + } + } + "Bridge (Direct): ${count(connection)} rows." + } + + private suspend fun bridgeWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.bridgeRoom2Db(context).songDao(), "Bridge (Room 2)", heavy) + // --- 2b. SentrySQLiteDriver, used through Room 3.0+ (androidx.room3) ----------------------- private suspend fun driverWithRoom3(context: Context, heavy: Boolean): String { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt index b32811e8c91..3cc6d394daa 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -3,6 +3,7 @@ package io.sentry.samples.android.sqlite import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.getValue @@ -48,7 +49,8 @@ class UiLoadActivity : ComponentActivity() { withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } "$result\n\nRan under the auto ui.load transaction." } catch (t: Throwable) { - "Load failed: ${t.message}" + Log.e(TAG, "Load failed", t) + "Load failed: ${t.message ?: t.javaClass.simpleName}" } finally { // Close the TTFD window so the ui.load transaction finishes with the db spans attached. Sentry.reportFullyDisplayed() @@ -57,6 +59,7 @@ class UiLoadActivity : ComponentActivity() { } companion object { + private const val TAG = "UiLoadActivity" private const val EXTRA_DEMO_ID = "demo_id" private const val EXTRA_HEAVY = "heavy" From 4aacc7079da54dfa53bdb4beae8411ff5d8a4db8 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 8 Jun 2026 14:38:16 +0200 Subject: [PATCH 12/15] fix(android-sqlite): Use warm-up generation counter for stale run guard --- .../io/sentry/samples/android/sqlite/SampleDatabases.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 62febaabef6..19b292cd91e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -53,6 +53,7 @@ object SampleDatabases { private set @Volatile private var warmUpComplete = false + @Volatile private var warmUpGeneration = 0 @Volatile private var warmUpJob: Job? = null fun isWarmUpComplete(): Boolean = warmUpComplete @@ -259,6 +260,7 @@ object SampleDatabases { /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ fun warmUp(context: Context) { val appContext = context.applicationContext + val generation = ++warmUpGeneration warmUpComplete = false warmUpErrors = "" // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. @@ -289,8 +291,10 @@ object SampleDatabases { .countSongs() .executeAsOne() } - warmUpErrors = failures.joinToString("\n") { "Warm-up failed: $it" } - warmUpComplete = true + if (generation == warmUpGeneration) { + warmUpErrors = failures.joinToString("\n") { "Warm-up failed: $it" } + warmUpComplete = true + } } } From 007c53702f9905e891c5a07d6a0b216e2b3b02ab Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 11 Jun 2026 13:39:55 +0200 Subject: [PATCH 13/15] Add Proguard keep rule for SupportSQLiteDriver --- sentry-android-sqlite/proguard-rules.pro | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry-android-sqlite/proguard-rules.pro b/sentry-android-sqlite/proguard-rules.pro index 02ab589d3bd..13fa4bf9dea 100644 --- a/sentry-android-sqlite/proguard-rules.pro +++ b/sentry-android-sqlite/proguard-rules.pro @@ -4,4 +4,8 @@ # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile +# SentrySQLiteDriver.create() uses a runtime class-name check to skip wrapping the Room 2.7+ +# SupportSQLiteDriver bridge adapter and avoid duplicate spans. +-keepnames class androidx.sqlite.driver.SupportSQLiteDriver + ##---------------End: proguard configuration for SQLite ---------- From b5725eafce59b329f8a968a1fc960276db6ce033 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Thu, 18 Jun 2026 10:09:29 +0000 Subject: [PATCH 14/15] Format code --- .../main/java/io/sentry/samples/android/sqlite/SqlStatements.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt index f4b7663ba9c..9bd2d624694 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -149,7 +149,7 @@ object SqlStatements { private suspend fun bridgeWithRoom2(context: Context, heavy: Boolean): String = roomDemo(SampleDatabases.bridgeRoom2Db(context).songDao(), "Bridge (Room 2)", heavy) - + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = From f28dcf52cc039826cd4b7794ed4ed5957b292953 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 18 Jun 2026 12:07:14 +0200 Subject: [PATCH 15/15] Restore SQLiteSpanManager changes from main + fix merge conflicts --- .../android/sqlite/SQLiteSpanManager.kt | 43 +++++++++++++++---- .../sentry/sqlite/SentrySQLiteDriverTest.kt | 1 + .../samples/android/sqlite/SQLiteActivity.kt | 2 +- 3 files changed, 37 insertions(+), 9 deletions(-) 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 3495d3a71f0..1bdeb7d369c 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,17 +3,21 @@ package io.sentry.android.sqlite 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 -import io.sentry.sqlite.SQLiteSpanInstrumentation + +private const val TRACE_ORIGIN = "auto.db.sqlite" internal class SQLiteSpanManager( private val scopes: IScopes = ScopesAdapter.getInstance(), - databaseName: String? = null, + private val databaseName: String? = null, ) { - - private val spans = SQLiteSpanInstrumentation.fromDatabaseName(databaseName, scopes) + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") @@ -29,8 +33,8 @@ internal class SQLiteSpanManager( @Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST") @Throws(SQLException::class) fun performSql(sql: String, operation: () -> T): T { - val startTimestamp = spans.startTimestamp() - + val startTimestamp = scopes.getOptions().dateProvider.now() + var span: ISpan? = null return try { val result = operation() /* @@ -41,11 +45,34 @@ internal class SQLiteSpanManager( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - spans.recordSpan(sql, startTimestamp, SpanStatus.OK) + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN + span?.status = SpanStatus.OK result } catch (e: Throwable) { - spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e) + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN + 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() + } } } } 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 index d8fb429bf40..5816f3d859c 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -3,6 +3,7 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import androidx.sqlite.SQLiteStatement +import androidx.sqlite.driver.SupportSQLiteDriver import io.sentry.IScopes import io.sentry.Sentry import io.sentry.SentryIntegrationPackageStorage diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index 9454002f982..9a27ecda353 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -364,7 +364,7 @@ class SQLiteActivity : ComponentActivity() { label = row.label, color = integration.color, variant = variant, - disabledReason = "${row.label} doesn't apply to the ${integration.apiName} stack", + disabledReason = "${row.label} doesn't support the ${integration.apiName} stack", ) }