Skip to content

Commit 804f413

Browse files
committed
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.
1 parent 5ae931e commit 804f413

7 files changed

Lines changed: 357 additions & 63 deletions

File tree

sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,18 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite
6868
public companion object {
6969

7070
/**
71-
* Fully-qualified class name of the bridge adapter often used with Room 2.7+. It implements the
72-
* `SQLiteDriver` interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of
73-
* the Sentry Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them
71+
* Name of the bridge adapter often used with Room 2.7+. It implements the `SQLiteDriver`
72+
* interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of the Sentry
73+
* Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them
7474
* automatically.) We deliberately avoid wrapping the adapter to prevent duplicate spans.
75+
*
76+
* String (rather than an `is` check) lets us avoid a compile-time dependency on
77+
* androidx.sqlite:sqlite-framework.
7578
*/
7679
private const val SUPPORT_SQLITE_DRIVER_FQN = "androidx.sqlite.driver.SupportSQLiteDriver"
7780

7881
@JvmStatic
7982
public fun create(delegate: SQLiteDriver): SQLiteDriver =
80-
// String rather than an `is` check for SupportSQLiteDriver to avoid a compile-time dependency
81-
// on androidx.sqlite:sqlite-framework.
8283
if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) {
8384
delegate
8485
} else {

sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ import androidx.sqlite.SQLiteDriver
77
* Minimal stub of `androidx.sqlite.driver.SupportSQLiteDriver` (which lives in
88
* `androidx.sqlite:sqlite-framework`, not on this module's compile/test classpath) for verifying
99
* behavior of `SentrySQLiteDriver.create(SupportSQLiteDriver)`.
10-
*
11-
* The production check is `delegate.javaClass.name ==
12-
* "androidx.sqlite.driver.SupportSQLiteDriver"`, so any class with this exact fully-qualified name
13-
* exercises the branch.
1410
*/
1511
internal class SupportSQLiteDriver : SQLiteDriver {
1612

sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ internal val OPENHELPER_ROOM =
8787
.trimIndent(),
8888
)
8989

90+
// Bridge demos run the same SQL as the driver paths; spans come from the open-helper layer.
91+
internal val BRIDGE_DIRECT = DRIVER_DIRECT
92+
93+
internal val BRIDGE_ROOM2 = DRIVER_ROOM2
94+
9095
internal val OPENHELPER_SQLDELIGHT =
9196
DisplayInfo(
9297
sql =

sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt

Lines changed: 146 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.sentry.samples.android.sqlite
22

33
import android.os.Bundle
4+
import android.util.Log
45
import android.widget.Toast
56
import androidx.activity.ComponentActivity
67
import androidx.activity.compose.setContent
@@ -33,6 +34,9 @@ import androidx.compose.material3.MaterialTheme
3334
import androidx.compose.material3.OutlinedTextField
3435
import androidx.compose.material3.OutlinedTextFieldDefaults
3536
import androidx.compose.material3.PlainTooltip
37+
import androidx.compose.material3.SegmentedButton
38+
import androidx.compose.material3.SegmentedButtonDefaults
39+
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
3640
import androidx.compose.material3.Surface
3741
import androidx.compose.material3.Switch
3842
import androidx.compose.material3.SwitchColors
@@ -73,6 +77,7 @@ import kotlinx.coroutines.withContext
7377

7478
private val SentryPink = Color(0xFFC85B9C)
7579
private val SentryPurple = Color(0xFF7B52FB)
80+
private val SentryOrange = Color(0xFFE8743F)
7681
private val SentryRed = Color(0xFFF55459)
7782

7883
/** 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
8893

8994
private val SECTION_HEADER_HEIGHT = 28.dp
9095

91-
/** Which sentry-android-sqlite integration the demo buttons currently target. */
92-
private enum class Integration(val color: Color, val apiName: String) {
93-
DRIVER(SentryPurple, "SQLiteDriver"),
94-
OPEN_HELPER(SentryPink, "SupportSQLiteOpenHelper"),
96+
/** Which sentry-android-sqlite integration the demo currently targets. */
97+
private enum class IntegrationMode(
98+
val color: Color,
99+
val segmentLabel: String,
100+
val apiName: String,
101+
val subtitle: String,
102+
) {
103+
DRIVER(
104+
SentryPurple,
105+
"SQLiteDriver",
106+
"SQLiteDriver",
107+
"SentrySQLiteDriver.create(BundledSQLiteDriver)",
108+
),
109+
OPEN_HELPER(
110+
SentryPink,
111+
"OpenHelper",
112+
"SupportSQLiteOpenHelper",
113+
"SentrySupportSQLiteOpenHelper.create(...)",
114+
),
115+
// Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER
116+
// integrations are used together via the SupportSQLiteDriver bridge.
117+
BRIDGE(
118+
SentryOrange,
119+
"Bridge",
120+
"SupportSQLiteDriver bridge",
121+
"SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))",
122+
),
95123
}
96124

97125
/**
@@ -107,11 +135,24 @@ private class DemoVariant(
107135
)
108136

109137
/**
110-
* A single demo button in the list. [driver] / [openHelper] hold the variant for each integration;
111-
* a null variant means the row doesn't apply to that integration and renders dimmed, explaining why
112-
* on click (Room 3 is driver-only; SQLDelight is open-helper-only).
138+
* A single demo button in the list. [driver] / [openHelper] / [bridge] hold the variant for each
139+
* integration; a null variant means the row doesn't apply and renders dimmed (e.g., Room 3 is
140+
* driver-only; SQLDelight is open-helper-only; etc.).
113141
*/
114-
private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?)
142+
private class DemoRow(
143+
val label: String,
144+
val driver: DemoVariant?,
145+
val openHelper: DemoVariant?,
146+
val bridge: DemoVariant?,
147+
) {
148+
149+
fun variantFor(mode: IntegrationMode): DemoVariant? =
150+
when (mode) {
151+
IntegrationMode.DRIVER -> driver
152+
IntegrationMode.OPEN_HELPER -> openHelper
153+
IntegrationMode.BRIDGE -> bridge
154+
}
155+
}
115156

116157
// The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual
117158
// SQL lives in SqlStatements, dispatched by id.
@@ -133,6 +174,13 @@ private val DEMO_ROWS =
133174
op = "db.sql.openhelper-direct",
134175
displayInfo = OPENHELPER_DIRECT,
135176
),
177+
bridge =
178+
DemoVariant(
179+
demo = SqlDemo.BRIDGE_DIRECT,
180+
transactionName = "Bridge stack — Direct",
181+
op = "db.sql.bridge-direct",
182+
displayInfo = BRIDGE_DIRECT,
183+
),
136184
),
137185
DemoRow(
138186
label = "Room 2",
@@ -150,6 +198,13 @@ private val DEMO_ROWS =
150198
op = "db.sql.openhelper-room",
151199
displayInfo = OPENHELPER_ROOM,
152200
),
201+
bridge =
202+
DemoVariant(
203+
demo = SqlDemo.BRIDGE_ROOM2,
204+
transactionName = "Bridge stack — Room 2",
205+
op = "db.sql.bridge-room2",
206+
displayInfo = BRIDGE_ROOM2,
207+
),
153208
),
154209
DemoRow(
155210
label = "Room 3",
@@ -161,6 +216,7 @@ private val DEMO_ROWS =
161216
displayInfo = DRIVER_ROOM3,
162217
),
163218
openHelper = null, // Room 3 only runs on the SQLiteDriver path.
219+
bridge = null,
164220
),
165221
DemoRow(
166222
label = "SQLDelight",
@@ -172,6 +228,7 @@ private val DEMO_ROWS =
172228
op = "db.sql.openhelper-sqldelight",
173229
displayInfo = OPENHELPER_SQLDELIGHT,
174230
),
231+
bridge = null,
175232
),
176233
)
177234

@@ -187,6 +244,7 @@ private val DEMO_ROWS =
187244
class SQLiteActivity : ComponentActivity() {
188245

189246
private var latestResult by mutableStateOf("")
247+
private var warmUpErrors by mutableStateOf("")
190248
private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT)
191249
private var heavyWork by mutableStateOf(false)
192250

@@ -198,8 +256,8 @@ class SQLiteActivity : ComponentActivity() {
198256
*/
199257
private var shareScreenTrace by mutableStateOf(false)
200258

201-
/** Which integration the demo buttons target. Switching it disables the rows that don't apply. */
202-
private var integration by mutableStateOf(Integration.DRIVER)
259+
/** Which integration is currently being demoed. Switching it disables rows that don't apply. */
260+
private var integration by mutableStateOf(IntegrationMode.DRIVER)
203261

204262
/** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */
205263
private var runTick by mutableStateOf(0)
@@ -265,31 +323,19 @@ class SQLiteActivity : ComponentActivity() {
265323

266324
SectionHeader("Configure it")
267325

268-
val openHelper = integration == Integration.OPEN_HELPER
269-
val integrationSwitchColors =
270-
SwitchDefaults.colors(
271-
checkedTrackColor = SentryPink,
272-
checkedBorderColor = SentryPink,
273-
uncheckedTrackColor = SentryPurple,
274-
uncheckedBorderColor = SentryPurple,
275-
uncheckedThumbColor = Color.White,
276-
)
277326
val controlSwitchColors =
278327
SwitchDefaults.colors(
279328
checkedTrackColor = Color.Black,
280329
checkedBorderColor = Color.Black,
281330
)
282-
ToggleRow(
283-
label = if (openHelper) "SentrySupportSQLiteOpenHelper" else "SentrySQLiteDriver",
284-
checked = openHelper,
285-
labelColor = if (openHelper) SentryPink else SentryPurple,
286-
switchColors = integrationSwitchColors,
287-
) {
288-
integration = if (it) Integration.OPEN_HELPER else Integration.DRIVER
289-
// Switching integration starts a fresh comparison: clear the detail box and result.
290-
sqlDetail = SQL_DETAIL_HINT
291-
latestResult = ""
292-
}
331+
IntegrationModeSelector(
332+
selected = integration,
333+
onSelected = {
334+
integration = it
335+
sqlDetail = SQL_DETAIL_HINT
336+
latestResult = ""
337+
},
338+
)
293339
ToggleRow(
294340
label = if (heavyWork) "Heavy app-level work" else "No app-level work",
295341
checked = heavyWork,
@@ -313,12 +359,12 @@ class SQLiteActivity : ComponentActivity() {
313359
// integration's variant; a row that doesn't apply explains why via a toast (see
314360
// [DemoRowButton]).
315361
DEMO_ROWS.forEach { row ->
316-
val variant = if (integration == Integration.DRIVER) row.driver else row.openHelper
362+
val variant = row.variantFor(integration)
317363
DemoRowButton(
318364
label = row.label,
319365
color = integration.color,
320366
variant = variant,
321-
disabledReason = "${row.label} doesn't use the ${integration.apiName}",
367+
disabledReason = "${row.label} doesn't apply to the ${integration.apiName} stack",
322368
)
323369
}
324370

@@ -330,12 +376,26 @@ class SQLiteActivity : ComponentActivity() {
330376
// Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from
331377
// the detail output.
332378
SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP)
379+
LaunchedEffect(Unit) {
380+
while (!SampleDatabases.isWarmUpComplete()) {
381+
warmUpErrors = SampleDatabases.warmUpErrors
382+
delay(250)
383+
}
384+
warmUpErrors = SampleDatabases.warmUpErrors
385+
}
386+
if (warmUpErrors.isNotEmpty()) {
387+
Text(
388+
text = warmUpErrors,
389+
style = MaterialTheme.typography.bodyMedium,
390+
color = SentryRed,
391+
)
392+
}
333393
// The latest run result (row counts, errors). Hidden until the first run.
334394
if (latestResult.isNotEmpty()) {
335395
Text(
336396
text = latestResult,
337397
style = MaterialTheme.typography.bodyMedium,
338-
color = if (latestResult.contains("failed")) SentryRed else Color.Unspecified,
398+
color = if (latestResult.looksLikeError()) SentryRed else Color.Unspecified,
339399
)
340400
}
341401
DetailField("SQL run", sqlDetail, borderColor = detailOutline)
@@ -361,12 +421,13 @@ class SQLiteActivity : ComponentActivity() {
361421
lifecycleScope.launch {
362422
dbOperationInFlight = true
363423
try {
364-
latestResult =
424+
val result =
365425
withContext(Dispatchers.IO) {
366426
runInTransaction(variant.transactionName, variant.op) {
367427
SqlStatements.execute(applicationContext, variant.demo, heavyWork)
368428
}
369429
}
430+
latestResult = result
370431
} finally {
371432
dbOperationInFlight = false
372433
}
@@ -385,9 +446,41 @@ class SQLiteActivity : ComponentActivity() {
385446
startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork))
386447
}
387448

449+
@OptIn(ExperimentalMaterial3Api::class)
450+
@androidx.compose.runtime.Composable
451+
private fun IntegrationModeSelector(
452+
selected: IntegrationMode,
453+
onSelected: (IntegrationMode) -> Unit,
454+
) {
455+
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
456+
IntegrationMode.entries.forEachIndexed { index, mode ->
457+
SegmentedButton(
458+
shape =
459+
SegmentedButtonDefaults.itemShape(index = index, count = IntegrationMode.entries.size),
460+
onClick = { onSelected(mode) },
461+
selected = selected == mode,
462+
icon = {},
463+
colors =
464+
SegmentedButtonDefaults.colors(
465+
activeContainerColor = mode.color,
466+
activeContentColor = Color.White,
467+
),
468+
label = { Text(mode.segmentLabel, style = MaterialTheme.typography.labelSmall) },
469+
)
470+
}
471+
}
472+
473+
Text(
474+
text = selected.subtitle,
475+
style = MaterialTheme.typography.bodySmall,
476+
color = Color.Gray,
477+
modifier = Modifier.padding(top = 6.dp),
478+
)
479+
}
480+
388481
/**
389482
* A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the
390-
* label inherits the default text color; the integration toggle passes its pink/purple instead.
483+
* label inherits the default text color.
391484
*/
392485
@androidx.compose.runtime.Composable
393486
private fun ToggleRow(
@@ -533,7 +626,11 @@ class SQLiteActivity : ComponentActivity() {
533626
try {
534627
val message = withContext(Dispatchers.IO) { resetDatabases() }
535628
latestResult = message
629+
warmUpErrors = SampleDatabases.warmUpErrors
536630
sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0."
631+
} catch (t: Throwable) {
632+
Log.e(TAG, "Reset failed", t)
633+
latestResult = "Reset failed: ${t.message ?: t.javaClass.simpleName}"
537634
} finally {
538635
this@SQLiteActivity.dbOperationInFlight = false
539636
this@SQLiteActivity.resetInProgress = false
@@ -595,7 +692,8 @@ class SQLiteActivity : ComponentActivity() {
595692
result
596693
} catch (t: Throwable) {
597694
transaction.status = SpanStatus.INTERNAL_ERROR
598-
"$transactionName failed: ${t.message}"
695+
Log.e(TAG, "$transactionName failed", t)
696+
"$transactionName failed: ${t.message ?: t.javaClass.simpleName}"
599697
} finally {
600698
transaction.finish()
601699
}
@@ -604,11 +702,20 @@ class SQLiteActivity : ComponentActivity() {
604702
/** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */
605703
private suspend fun resetDatabases(): String {
606704
val cleared = SampleDatabases.reset(applicationContext)
607-
return "Dropped tables: cleared $cleared database file(s)."
705+
SampleDatabases.awaitWarmUp()
706+
return buildString {
707+
append("Dropped tables: cleared $cleared database file(s).")
708+
if (SampleDatabases.warmUpErrors.isNotEmpty()) {
709+
append("\n\n")
710+
append(SampleDatabases.warmUpErrors)
711+
}
712+
}
608713
}
609714

610715
private companion object {
611716

717+
private const val TAG = "SQLiteActivity"
718+
612719
/** Demo SQL shorter than this won't visibly disable the reset button. */
613720
private const val RESET_DISABLE_DEBOUNCE_MS = 300L
614721

@@ -619,3 +726,5 @@ class SQLiteActivity : ComponentActivity() {
619726
private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1"
620727
}
621728
}
729+
730+
private fun String.looksLikeError(): Boolean = contains("failed", ignoreCase = true)

0 commit comments

Comments
 (0)