From 5ae931e15b116b312e3642da2df6979d7d79492a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 6 Jun 2026 10:26:59 +0200 Subject: [PATCH 1/3] 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 30b7e2a85b..dd8fc49b3e 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 7c7e24ac07..44e29605b3 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 0000000000..013b078239 --- /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 9b2345a975..5816f3d859 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 2/3] 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 44e29605b3..2c43a34422 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 013b078239..2de7f1d38f 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 14582fe305..fd80a5aae1 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 1ff6828a75..9454002f98 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 63f217fcfb..62febaabef 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 543f116929..a3646414bb 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 b32811e8c9..3cc6d394da 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 3/3] 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 62febaabef..19b292cd91 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 + } } }