diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5c286dc794..710a794218 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 0000000000..b2efa441c3
--- /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 ed8cea2566..e7122028a9 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 e5b5ed2250..23ef7e62c7 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 0000000000..145e12d389
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt
@@ -0,0 +1,42 @@
+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
+
+ /**
+ * 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)
+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 0000000000..f0492c0b47
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt
@@ -0,0 +1,617 @@
+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)
+
+ /** 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.
+ */
+ 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(
+ dbOperationInFlight = dbOperationInFlight,
+ resetInProgress = resetInProgress,
+ )
+
+ // 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 {
+ dbOperationInFlight = true
+ try {
+ latestResult =
+ withContext(Dispatchers.IO) {
+ runInTransaction(variant.transactionName, variant.op) {
+ SqlStatements.execute(applicationContext, variant.demo, heavyWork)
+ }
+ }
+ } finally {
+ dbOperationInFlight = false
+ }
+ }
+ }
+
+ /**
+ * 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(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 {
+ 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
+ }
+ }
+ },
+ ) {
+ 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 {
+
+ /** 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.
+ */
+ 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 0000000000..8479a5c1eb
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt
@@ -0,0 +1,215 @@
+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 { 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()
+ }
+ }
+ }
+
+ /**
+ * 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 0000000000..345e55a358
--- /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 0000000000..61dd2c732a
--- /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 0000000000..036ba4332a
--- /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 0000000000..6495726448
--- /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,
+ )
+ },
+ )
+}