diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1b7caf8b..162d973a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,21 @@
## Unreleased
+### Features
+
+- Auto-instrument SQLiteDriver for Room users ([#1285](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1285))
+ - Gated on `sentry-android-sqlite` >= 8.45.0 and the existing `tracingInstrumentation` `DATABASE` feature (enabled by default)
+ - For users of the `androidx.sqlite.driver.SupportSQLiteDriver` bridge, auto-instrumentation wraps only the `SupportSQLiteOpenHelper` consumed by the bridge and not the bridge itself (avoids duplicate spans)
+
### Security
- Pin the plugin's build dependencies with Gradle dependency locking and SHA-256 dependency verification ([#1256](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1256))
### Dependencies
+- Bump Android SDK from v8.44.1 to v8.45.0 ([#1285](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1285))
+ - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8450)
+ - [diff](https://github.com/getsentry/sentry-java/compare/8.44.1...8.45.0)
- Bump Android SDK from v8.44.0 to v8.44.1 ([#1305](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1305))
- [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8441)
- [diff](https://github.com/getsentry/sentry-java/compare/8.44.0...8.44.1)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 601896796..2b32ba67d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,7 +4,7 @@ agp = "8.10.1"
asm = "9.4" # // compatibility matrix -> https://developer.android.com/reference/tools/gradle-api/7.1/com/android/build/api/instrumentation/InstrumentationContext#apiversion
ktfmt = "0.51"
sqlite = "2.1.0"
-sentry = "8.44.1"
+sentry = "8.45.0"
# Pinned to the last release that shipped sentry-android-okhttp; the module was removed
# in 8.0.0, so there is no newer version to update to. Used only to verify the legacy
# okhttp instrumentation path in tests.
@@ -60,8 +60,11 @@ sentryAndroidOkhttp = { group = "io.sentry", name = "sentry-android-okhttp", ver
sentrySpringBootJakarta = { group = "io.sentry", name = "sentry-spring-boot-starter-jakarta", version.ref = "sentry" }
# test
-mockitoKotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "5.4.0" }
arscLib = { group = "io.github.reandroid", name = "ARSCLib", version = "1.1.4" }
+mockitoKotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "5.4.0" }
+# Room & Room 3 runtime versions must match RoomDatabase$Builder bytecode fixtures (see SQLiteDriverBytecodeTestUtil)
+roomRuntimeAndroid = { group = "androidx.room", name = "room-runtime-android", version = "2.7.0" }
+room3RuntimeAndroid = { group = "androidx.room3", name = "room3-runtime-android", version = "3.0.0-alpha06" }
zip4j = { group = "net.lingala.zip4j", name = "zip4j", version = "2.11.5" }
# samples
diff --git a/plugin-build/build.gradle.kts b/plugin-build/build.gradle.kts
index 043e2a39c..d211558b7 100644
--- a/plugin-build/build.gradle.kts
+++ b/plugin-build/build.gradle.kts
@@ -55,12 +55,16 @@ dependencies {
testImplementation(libs.asmCommons)
// we need these dependencies for tests, because the bytecode verifier also analyzes superclasses
+ testImplementationAar(libs.roomRuntimeAndroid)
+ testImplementationAar(libs.room3RuntimeAndroid)
+ testImplementation(libs.sample.coroutines.core)
+ testImplementationAar(libs.sentryAndroid)
testImplementationAar(libs.sqlite)
testImplementationAar(libs.sqliteFramework)
- testRuntimeOnly(files(androidSdkPath))
- testImplementationAar(libs.sentryAndroid)
- testImplementation(libs.sentryOkhttp)
testImplementationAar(libs.sentryAndroidOkhttp)
+ testImplementation(libs.sentryOkhttp)
+
+ testRuntimeOnly(files(androidSdkPath))
// Needed to read contents from APK/Source Bundles
testImplementation(libs.arscLib)
diff --git a/plugin-build/gradle.lockfile b/plugin-build/gradle.lockfile
index 0e8af9468..ae2dcb6b2 100644
--- a/plugin-build/gradle.lockfile
+++ b/plugin-build/gradle.lockfile
@@ -3,6 +3,8 @@
# This file is expected to be part of source control.
androidx.databinding:databinding-common:8.10.1=testRuntimeClasspath
androidx.databinding:databinding-compiler-common:8.10.1=testRuntimeClasspath
+androidx.room3:room3-runtime-android:3.0.0-alpha06=testImplementationAar
+androidx.room:room-runtime-android:2.7.0=testImplementationAar
androidx.sqlite:sqlite-framework:2.1.0=testImplementationAar
androidx.sqlite:sqlite:2.1.0=testImplementationAar
com.android.databinding:baseLibrary:8.10.1=testRuntimeClasspath
@@ -108,9 +110,9 @@ io.netty:netty-transport-native-unix-common:4.1.110.Final=testRuntimeClasspath
io.netty:netty-transport:4.1.110.Final=testRuntimeClasspath
io.perfmark:perfmark-api:0.27.0=testRuntimeClasspath
io.sentry:sentry-android-okhttp:7.22.6=testImplementationAar
-io.sentry:sentry-android:8.44.1=testImplementationAar
-io.sentry:sentry-okhttp:8.44.1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-io.sentry:sentry:8.44.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+io.sentry:sentry-android:8.45.0=testImplementationAar
+io.sentry:sentry-okhttp:8.45.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+io.sentry:sentry:8.45.0=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
it.unimi.dsi:fastutil-core:8.5.12=dokkaGfmPlugin,dokkaHtmlPlugin,dokkaJavadocPlugin,dokkaJekyllPlugin
jakarta.activation:jakarta.activation-api:1.2.1=dokkaGfmRuntime,dokkaHtmlRuntime,dokkaJavadocRuntime,dokkaJekyllRuntime,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=dokkaGfmRuntime,dokkaHtmlRuntime,dokkaJavadocRuntime,dokkaJekyllRuntime,testRuntimeClasspath
@@ -176,7 +178,7 @@ org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.21=kotlinBuil
org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime
org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0=compileClasspath,compileOnlyDependenciesMetadata
-org.jetbrains.kotlin:kotlin-stdlib-common:2.1.21=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.jetbrains.kotlin:kotlin-stdlib-common:2.2.20=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=dokkaGfmRuntime,dokkaHtmlRuntime,dokkaJavadocRuntime,dokkaJekyllRuntime
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0=dokkaGfmPlugin,dokkaHtmlPlugin,dokkaJavadocPlugin,dokkaJekyllPlugin
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0=testRuntimeClasspath
@@ -185,18 +187,23 @@ org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0=dokkaGfmPlugin,dokkaHtmlPlugin,dok
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0=testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:1.9.22=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime
org.jetbrains.kotlin:kotlin-stdlib:2.0.0=compileClasspath,compileOnlyDependenciesMetadata
-org.jetbrains.kotlin:kotlin-stdlib:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.jetbrains.kotlin:kotlin-stdlib:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-stdlib:2.2.20=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-test-junit:2.1.21=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-test:2.1.21=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-tooling-core:2.1.21=compileClasspath,compileOnlyDependenciesMetadata
org.jetbrains.kotlin:kotlin-util-io:2.1.21=compileClasspath,compileOnlyDependenciesMetadata
+org.jetbrains.kotlinx:atomicfu:0.23.1=testImplementationDependenciesMetadata
+org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime
+org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime
org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1=dokkaGfmPlugin,dokkaHtmlPlugin,dokkaJavadocPlugin,dokkaJekyllPlugin
-org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,testCompileClasspath
-org.jetbrains:annotations:23.0.0=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime
+org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath
+org.jetbrains:annotations:23.0.0=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime,testCompileClasspath
org.jetbrains:annotations:24.0.0=testRuntimeClasspath
org.jetbrains:annotations:24.0.1=compileClasspath,compileOnlyDependenciesMetadata
org.jetbrains:markdown-jvm:0.5.2=dokkaGfmPlugin,dokkaHtmlPlugin,dokkaJavadocPlugin,dokkaJekyllPlugin
diff --git a/plugin-build/gradle/verification-metadata.xml b/plugin-build/gradle/verification-metadata.xml
index 7ec1bdbc0..736c85858 100644
--- a/plugin-build/gradle/verification-metadata.xml
+++ b/plugin-build/gradle/verification-metadata.xml
@@ -21,6 +21,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1454,6 +1470,14 @@
+
+
+
+
+
+
+
+
@@ -1486,6 +1510,14 @@
+
+
+
+
+
+
+
+
@@ -1526,6 +1558,14 @@
+
+
+
+
+
+
+
+
@@ -2919,6 +2959,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -2960,6 +3011,11 @@
+
+
+
+
+
@@ -3202,6 +3258,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3217,6 +3286,14 @@
+
+
+
+
+
+
+
+
@@ -3227,6 +3304,14 @@
+
+
+
+
+
+
+
+
diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt
index d51ca26f4..f23176c34 100644
--- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt
+++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt
@@ -75,9 +75,16 @@ open class TracingInstrumentationExtension @Inject constructor(objects: ObjectFa
enum class InstrumentationFeature(val integrationName: String) {
/**
- * When enabled the SDK will create spans for any CRUD operation performed by
- * 'androidx.sqlite.db.SupportSQLiteOpenHelper' and 'androidx.room'. This feature uses bytecode
- * manipulation.
+ * When enabled the SDK will create spans for database operations at two levels:
+ *
+ * **SQL statement execution** (`db.sql.query` spans): Wraps the low-level `SQLiteDriver` or
+ * `SupportSQLiteOpenHelper` so each SQL statement produces one or more spans.
+ *
+ * **DAO method execution** (`db.sql.room` spans): Wraps each public method on Room's generated
+ * `@Dao` `_Impl` classes, measuring the full DAO call end-to-end (transaction management, query
+ * execution, and cursor processing). Only for Room users on < Room 2.7.
+ *
+ * This feature uses bytecode manipulation.
*/
DATABASE("DatabaseInstrumentation"),
diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt
index fd4699f9c..0a22d2b68 100644
--- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt
+++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt
@@ -10,6 +10,7 @@ import io.sentry.android.gradle.instrumentation.androidx.compose.ComposeNavigati
import io.sentry.android.gradle.instrumentation.androidx.room.AndroidXRoomDao
import io.sentry.android.gradle.instrumentation.androidx.sqlite.AndroidXSQLiteOpenHelper
import io.sentry.android.gradle.instrumentation.androidx.sqlite.database.AndroidXSQLiteDatabase
+import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.AndroidXSQLiteDriver
import io.sentry.android.gradle.instrumentation.androidx.sqlite.statement.AndroidXSQLiteStatement
import io.sentry.android.gradle.instrumentation.appstart.Application
import io.sentry.android.gradle.instrumentation.appstart.ContentProvider
@@ -90,10 +91,12 @@ abstract class SpanAddingClassVisitorFactory :
ChainedInstrumentable(
listOfNotNull(
AndroidXSQLiteOpenHelper().takeIf { sentryModulesService.isNewDatabaseInstrEnabled() },
+ AndroidXSQLiteDriver().takeIf { sentryModulesService.isSQLiteDriverInstrEnabled() },
AndroidXSQLiteDatabase().takeIf { sentryModulesService.isOldDatabaseInstrEnabled() },
AndroidXSQLiteStatement(androidXSqliteFrameWorkVersion).takeIf {
sentryModulesService.isOldDatabaseInstrEnabled()
},
+ // Note that DAO spans no longer work on Room 2.7+ or Room 3.0+ due to Room API changes.
AndroidXRoomDao().takeIf {
sentryModulesService.isNewDatabaseInstrEnabled() ||
sentryModulesService.isOldDatabaseInstrEnabled()
diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriver.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriver.kt
new file mode 100644
index 000000000..f882bb4e0
--- /dev/null
+++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriver.kt
@@ -0,0 +1,92 @@
+package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver
+
+import com.android.build.api.instrumentation.ClassContext
+import io.sentry.android.gradle.instrumentation.ClassInstrumentable
+import io.sentry.android.gradle.instrumentation.CommonClassVisitor
+import io.sentry.android.gradle.instrumentation.MethodContext
+import io.sentry.android.gradle.instrumentation.MethodInstrumentable
+import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory
+import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor.SetDriverMethodVisitor
+import org.objectweb.asm.ClassVisitor
+import org.objectweb.asm.MethodVisitor
+
+/** JVM type descriptor for `androidx.sqlite.SQLiteDriver`. */
+internal const val SQLITE_DRIVER_TYPE_DESCRIPTOR = "Landroidx/sqlite/SQLiteDriver;"
+
+/**
+ * Auto-instruments `SQLiteDriver` for all Room users by wrapping any driver passed to
+ * `RoomDatabase.Builder.setDriver(SQLiteDriver)`.
+ *
+ * In other words, this:
+ * ```kotlin
+ * val someDriver = AndroidSQLiteDriver()
+ * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName")
+ * .setDriver(someDriver)
+ * .build()
+ * ```
+ *
+ * becomes:
+ * ```kotlin
+ * val someDriver = AndroidSQLiteDriver()
+ * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName")
+ * .setDriver(SentrySQLiteDriver.create(someDriver))
+ * .build()
+ * ```
+ *
+ * `SentrySQLiteDriver` protects against duplicate wrappings, allowing the visitor to wrap
+ * `SQLiteDriver` unconditionally.
+ *
+ * Coverage is limited to Room because SQLDelight
+ * [doesn't support `SQLiteDriver`](https://github.com/sqldelight/sqldelight/issues/6072) (it uses
+ * `SupportSQLiteOpenHelper`, which we auto-instrument via
+ * [AndroidXSQLiteOpenHelper][io.sentry.android.gradle.instrumentation.androidx.sqlite.AndroidXSQLiteOpenHelper]).
+ * To keep our implementation simple and build times fast, developers who use `SQLiteDriver`
+ * directly are expected to wrap it themselves.
+ */
+class AndroidXSQLiteDriver : ClassInstrumentable {
+
+ override fun getVisitor(
+ instrumentableContext: ClassContext,
+ apiVersion: Int,
+ originalVisitor: ClassVisitor,
+ parameters: SpanAddingClassVisitorFactory.SpanAddingParameters,
+ ): ClassVisitor {
+ val currentClassName = instrumentableContext.currentClassData.className
+
+ return CommonClassVisitor(
+ apiVersion = apiVersion,
+ classVisitor = originalVisitor,
+ className = currentClassName.substringAfterLast('.'),
+ methodInstrumentables = listOf(SetDriverMethodInstrumentable()),
+ parameters = parameters,
+ )
+ }
+
+ override fun isInstrumentable(data: ClassContext): Boolean =
+ data.currentClassData.className in TARGET_CLASSES
+
+ companion object {
+
+ // Currently covers Room 2 and Room 3 packages. Update as needed.
+ internal val TARGET_CLASSES =
+ setOf("androidx.room.RoomDatabase\$Builder", "androidx.room3.RoomDatabase\$Builder")
+ }
+}
+
+class SetDriverMethodInstrumentable : MethodInstrumentable {
+
+ override fun getVisitor(
+ instrumentableContext: MethodContext,
+ apiVersion: Int,
+ originalVisitor: MethodVisitor,
+ parameters: SpanAddingClassVisitorFactory.SpanAddingParameters,
+ ): MethodVisitor = SetDriverMethodVisitor(apiVersion, originalVisitor, instrumentableContext)
+
+ override fun isInstrumentable(data: MethodContext): Boolean =
+ data.name == SET_DRIVER && data.descriptor?.startsWith(SET_DRIVER_DESCRIPTOR_PREFIX) == true
+
+ companion object {
+ internal const val SET_DRIVER = "setDriver"
+ internal const val SET_DRIVER_DESCRIPTOR_PREFIX = "($SQLITE_DRIVER_TYPE_DESCRIPTOR)"
+ }
+}
diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitor.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitor.kt
new file mode 100644
index 000000000..06dd12516
--- /dev/null
+++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitor.kt
@@ -0,0 +1,39 @@
+package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor
+
+import io.sentry.android.gradle.instrumentation.MethodContext
+import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.SQLITE_DRIVER_TYPE_DESCRIPTOR
+import org.objectweb.asm.MethodVisitor
+import org.objectweb.asm.Type
+import org.objectweb.asm.commons.AdviceAdapter
+import org.objectweb.asm.commons.Method
+
+class SetDriverMethodVisitor(
+ apiVersion: Int,
+ originalVisitor: MethodVisitor,
+ instrumentableContext: MethodContext,
+) :
+ AdviceAdapter(
+ apiVersion,
+ originalVisitor,
+ instrumentableContext.access,
+ instrumentableContext.name,
+ instrumentableContext.descriptor,
+ ) {
+
+ override fun onMethodEnter() {
+ // Inject at the start of RoomDatabase.Builder.setDriver(driver) so the method
+ // receives SentrySQLiteDriver.create(driver) instead of the raw driver (arg0).
+ loadArg(0)
+ invokeStatic(Type.getType(SENTRY_SQLITE_DRIVER_TYPE), Method(CREATE, SENTRY_CREATE_DESCRIPTOR))
+ storeArg(0)
+ }
+
+ companion object {
+ internal const val CREATE = "create"
+ // Must match SentrySQLiteDriver.create(SQLiteDriver): SQLiteDriver in sentry-android-sqlite:
+ // https://github.com/getsentry/sentry-java/blob/main/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt
+ internal const val SENTRY_CREATE_DESCRIPTOR =
+ "($SQLITE_DRIVER_TYPE_DESCRIPTOR)$SQLITE_DRIVER_TYPE_DESCRIPTOR"
+ internal const val SENTRY_SQLITE_DRIVER_TYPE = "Lio/sentry/sqlite/SentrySQLiteDriver;"
+ }
+}
diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt
index 29ba71bb3..b7d5aacf6 100644
--- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt
+++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt
@@ -72,6 +72,19 @@ abstract class SentryModulesService :
sentryModules.isAtLeast(SentryModules.SENTRY_ANDROID_SQLITE, SentryVersions.VERSION_SQLITE) &&
parameters.features.get().contains(InstrumentationFeature.DATABASE)
+ /**
+ * Returns true when the owning app uses a version of sentry-android-sqlite that contains
+ * `SentrySQLiteDriver` and the DATABASE feature is enabled.
+ *
+ * For simplicity we avoid gating on the Room version and rely on visitor behavior instead (no-ops
+ * for versions of Room without a `SQLiteDriver`).
+ */
+ fun isSQLiteDriverInstrEnabled(): Boolean =
+ sentryModules.isAtLeast(
+ SentryModules.SENTRY_ANDROID_SQLITE,
+ SentryVersions.VERSION_SQLITE_DRIVER,
+ ) && parameters.features.get().contains(InstrumentationFeature.DATABASE)
+
fun isOldDatabaseInstrEnabled(): Boolean =
!isNewDatabaseInstrEnabled() &&
sentryModules.isAtLeast(
diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt
index 8485bde93..c7e96281e 100644
--- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt
+++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt
@@ -33,6 +33,7 @@ internal object SentryVersions {
internal val VERSION_LOGCAT = SemVer(6, 17, 0)
internal val VERSION_APP_START = SemVer(7, 1, 0)
internal val VERSION_SQLITE = SemVer(6, 21, 0)
+ internal val VERSION_SQLITE_DRIVER = SemVer(8, 45, 0)
internal val VERSION_ANDROID_OKHTTP_LISTENER = SemVer(6, 20, 0)
internal val VERSION_OKHTTP = SemVer(7, 0, 0)
}
diff --git a/plugin-build/src/test/kotlin/androidx/sqlite/SQLiteDriver.kt b/plugin-build/src/test/kotlin/androidx/sqlite/SQLiteDriver.kt
new file mode 100644
index 000000000..7e3944dcf
--- /dev/null
+++ b/plugin-build/src/test/kotlin/androidx/sqlite/SQLiteDriver.kt
@@ -0,0 +1,12 @@
+package androidx.sqlite
+
+/**
+ * Minimal stub of `androidx.sqlite.SQLiteDriver` so ASM can resolve the type referenced by the
+ * instrumented bytecode.
+ *
+ * Must not coexist with androidx.sqlite >= 2.5 on the test classpath: that version introduces the
+ * real `SQLiteDriver`, which would collide with this stub. Safe today because `libs.sqlite` is
+ * pinned below 2.5 and `testImplementationAar` does not pull transitive dependencies (Room 2.7+
+ * depends on androidx.sqlite 2.5+, but `Aar2JarPlugin` sets `isTransitive = false`).
+ */
+interface SQLiteDriver
diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt
index 56ba455bf..819d56cc3 100644
--- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt
+++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt
@@ -5,6 +5,7 @@ import io.sentry.android.gradle.instrumentation.androidx.compose.ComposeNavigati
import io.sentry.android.gradle.instrumentation.androidx.room.AndroidXRoomDao
import io.sentry.android.gradle.instrumentation.androidx.sqlite.AndroidXSQLiteOpenHelper
import io.sentry.android.gradle.instrumentation.androidx.sqlite.database.AndroidXSQLiteDatabase
+import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.AndroidXSQLiteDriver
import io.sentry.android.gradle.instrumentation.androidx.sqlite.statement.AndroidXSQLiteStatement
import io.sentry.android.gradle.instrumentation.appstart.Application
import io.sentry.android.gradle.instrumentation.appstart.ContentProvider
@@ -122,6 +123,20 @@ class VisitorTest(
AndroidXSQLiteStatement(SemVer(2, 3, 0)),
null,
),
+ // RoomDatabase$Builder fixtures: see SQLiteDriverBytecodeTestUtil (extracted from published
+ // AARs).
+ arrayOf(
+ "androidxRoom",
+ "RoomDatabase\$Builder",
+ AndroidXSQLiteDriver(),
+ TestClassContext("androidx.room.RoomDatabase\$Builder"),
+ ),
+ arrayOf(
+ "androidxRoom",
+ "RoomDatabase3\$Builder",
+ AndroidXSQLiteDriver(),
+ TestClassContext("androidx.room3.RoomDatabase\$Builder"),
+ ),
roomDaoTestParameters("DeleteAndReturnUnit"),
roomDaoTestParameters("InsertAndReturnLong"),
roomDaoTestParameters("InsertAndReturnUnit"),
diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriverTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriverTest.kt
new file mode 100644
index 000000000..458413a67
--- /dev/null
+++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriverTest.kt
@@ -0,0 +1,95 @@
+@file:Suppress("UnstableApiUsage")
+
+package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver
+
+import io.sentry.android.gradle.instrumentation.ChainedInstrumentable
+import io.sentry.android.gradle.instrumentation.ClassInstrumentable
+import io.sentry.android.gradle.instrumentation.fakes.TestClassContext
+import io.sentry.android.gradle.instrumentation.fakes.TestSpanAddingParameters
+import java.io.FileInputStream
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.objectweb.asm.ClassReader
+import org.objectweb.asm.ClassWriter
+import org.objectweb.asm.Opcodes
+
+class AndroidXSQLiteDriverTest {
+
+ @get:Rule val tmpDir = TemporaryFolder()
+
+ private val instrumentable = AndroidXSQLiteDriver()
+
+ @Test
+ fun `isInstrumentable returns true for RoomDatabase Builder classes`() {
+ assertTrue(
+ instrumentable.isInstrumentable(TestClassContext("androidx.room.RoomDatabase\$Builder"))
+ )
+ assertTrue(
+ instrumentable.isInstrumentable(TestClassContext("androidx.room3.RoomDatabase\$Builder"))
+ )
+ }
+
+ @Test
+ fun `isInstrumentable returns false for unrelated classes`() {
+ assertFalse(instrumentable.isInstrumentable(TestClassContext("com.example.RoomConfig")))
+ assertFalse(instrumentable.isInstrumentable(TestClassContext("io.sentry.Sentry")))
+ assertFalse(instrumentable.isInstrumentable(TestClassContext("com.example.FakeSetDriver")))
+ }
+
+ @Test
+ fun `ChainedInstrumentable does not instrument unrelated classes`() {
+ val className = "com.example.NoSetDriver"
+ val originalBytes = loadNoSetDriverFixtureBytes()
+ val instrumentedBytes = instrumentThroughChain(className, originalBytes)
+
+ assertEquals(0, SQLiteDriverBytecodeTestUtil.countWrapCalls(instrumentedBytes))
+ }
+
+ @Test
+ fun `does not wrap when a visited class has no setDriver method`() {
+ // The Room < 2.7 production path: RoomDatabase$Builder matches the allowlist (so getVisitor
+ // runs), but the class has no setDriver(SQLiteDriver) and must pass through without a wrap.
+ val instrumentedBytes =
+ instrumentDirectly("com.example.NoSetDriver", loadNoSetDriverFixtureBytes())
+
+ assertEquals(0, SQLiteDriverBytecodeTestUtil.countWrapCalls(instrumentedBytes))
+ }
+
+ private fun instrumentThroughChain(className: String, bytes: ByteArray): ByteArray =
+ instrument(ChainedInstrumentable(listOf(instrumentable)), className, bytes)
+
+ private fun instrumentDirectly(className: String, bytes: ByteArray): ByteArray =
+ instrument(instrumentable, className, bytes)
+
+ private fun instrument(
+ classInstrumentable: ClassInstrumentable,
+ className: String,
+ bytes: ByteArray,
+ ): ByteArray {
+ val classReader = ClassReader(bytes)
+ val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
+ val classVisitor =
+ classInstrumentable.getVisitor(
+ TestClassContext(className),
+ Opcodes.ASM9,
+ classWriter,
+ parameters = TestSpanAddingParameters(inMemoryDir = tmpDir.root),
+ )
+ classReader.accept(classVisitor, ClassReader.SKIP_FRAMES)
+ return classWriter.toByteArray()
+ }
+
+ /**
+ * `NoSetDriver.class`: hand-compiled `public class NoSetDriver { int unrelated(int) }`. Shape is
+ * irrelevant / any class without `setDriver(SQLiteDriver)` works.
+ */
+ private fun loadNoSetDriverFixtureBytes(): ByteArray =
+ FileInputStream(
+ "src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class"
+ )
+ .use { it.readBytes() }
+}
diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SQLiteDriverBytecodeTestUtil.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SQLiteDriverBytecodeTestUtil.kt
new file mode 100644
index 000000000..918f6d5c1
--- /dev/null
+++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SQLiteDriverBytecodeTestUtil.kt
@@ -0,0 +1,55 @@
+package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver
+
+import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor.SetDriverMethodVisitor
+import java.io.FileInputStream
+import org.objectweb.asm.ClassReader
+import org.objectweb.asm.Opcodes
+import org.objectweb.asm.Type
+import org.objectweb.asm.tree.ClassNode
+import org.objectweb.asm.tree.MethodInsnNode
+import org.objectweb.asm.tree.MethodNode
+
+internal object SQLiteDriverBytecodeTestUtil {
+
+ private const val FIXTURES_ROOT = "src/test/resources/testFixtures/instrumentation/androidxRoom"
+
+ /**
+ * Room `Builder` bytecode fixtures extracted from published AARs:
+ * - `RoomDatabase$Builder.class`: `androidx.room:room-runtime-android:2.7.0`
+ * - `RoomDatabase3$Builder.class`: `androidx.room3:room3-runtime-android:3.0.0-alpha06`
+ *
+ * Extract from Google Maven by unzipping each AAR's `classes.jar` and copying
+ * `androidx/room/RoomDatabase$Builder.class` (or `androidx/room3/...`).
+ *
+ * `VisitorTest` needs matching Room runtime AARs (and coroutines) on the test classpath so ASM
+ * can resolve types referenced by the real bytecode.
+ */
+ private val CLASS_NAME_TO_FIXTURE =
+ mapOf(
+ "androidx.room.RoomDatabase\$Builder" to "RoomDatabase\$Builder",
+ "androidx.room3.RoomDatabase\$Builder" to "RoomDatabase3\$Builder",
+ )
+
+ fun loadRoomBuilderFixture(className: String): ByteArray {
+ val fixtureName =
+ CLASS_NAME_TO_FIXTURE[className] ?: error("No committed fixture for class $className")
+ return FileInputStream("$FIXTURES_ROOT/$fixtureName.class").use { it.readBytes() }
+ }
+
+ fun isWrapCall(insn: MethodInsnNode): Boolean =
+ insn.opcode == Opcodes.INVOKESTATIC &&
+ insn.owner == Type.getType(SetDriverMethodVisitor.SENTRY_SQLITE_DRIVER_TYPE).internalName &&
+ insn.name == SetDriverMethodVisitor.CREATE &&
+ insn.desc == SetDriverMethodVisitor.SENTRY_CREATE_DESCRIPTOR
+
+ fun isSetDriverDescriptor(descriptor: String): Boolean =
+ descriptor.startsWith(SetDriverMethodInstrumentable.SET_DRIVER_DESCRIPTOR_PREFIX)
+
+ fun countWrapCalls(bytes: ByteArray): Int {
+ val classNode = ClassNode().also { ClassReader(bytes).accept(it, 0) }
+ return classNode.methods.sumOf(::countWrapCalls)
+ }
+
+ fun countWrapCalls(method: MethodNode): Int =
+ method.instructions.filterIsInstance().count(::isWrapCall)
+}
diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SetDriverMethodInstrumentableTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SetDriverMethodInstrumentableTest.kt
new file mode 100644
index 000000000..421dabeb0
--- /dev/null
+++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SetDriverMethodInstrumentableTest.kt
@@ -0,0 +1,48 @@
+package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver
+
+import io.sentry.android.gradle.instrumentation.MethodContext
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.objectweb.asm.Opcodes
+
+class SetDriverMethodInstrumentableTest {
+
+ private val instrumentable = SetDriverMethodInstrumentable()
+
+ @Test
+ fun `isInstrumentable returns true for setDriver with SQLiteDriver parameter`() {
+ assertTrue(
+ instrumentable.isInstrumentable(
+ methodContext(
+ SetDriverMethodInstrumentable.SET_DRIVER,
+ "${SetDriverMethodInstrumentable.SET_DRIVER_DESCRIPTOR_PREFIX}Landroidx/room/RoomDatabase\$Builder;",
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `isInstrumentable returns false for unrelated method names`() {
+ assertFalse(instrumentable.isInstrumentable(methodContext("build", "()V")))
+ }
+
+ @Test
+ fun `isInstrumentable returns false for setDriver with non-SQLiteDriver descriptor`() {
+ assertFalse(
+ instrumentable.isInstrumentable(
+ methodContext(SetDriverMethodInstrumentable.SET_DRIVER, "(Ljava/lang/Object;)V")
+ )
+ )
+ }
+
+ @Test
+ fun `isInstrumentable returns false when descriptor is null`() {
+ assertFalse(
+ instrumentable.isInstrumentable(methodContext(SetDriverMethodInstrumentable.SET_DRIVER, null))
+ )
+ }
+
+ private fun methodContext(name: String, descriptor: String?) =
+ MethodContext(Opcodes.ACC_PUBLIC, name, descriptor, null, null)
+}
diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitorTest.kt
new file mode 100644
index 000000000..92f728473
--- /dev/null
+++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitorTest.kt
@@ -0,0 +1,89 @@
+@file:Suppress("UnstableApiUsage")
+
+package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor
+
+import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.AndroidXSQLiteDriver
+import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.SQLiteDriverBytecodeTestUtil
+import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.SetDriverMethodInstrumentable
+import io.sentry.android.gradle.instrumentation.fakes.TestClassContext
+import io.sentry.android.gradle.instrumentation.fakes.TestSpanAddingParameters
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.objectweb.asm.ClassReader
+import org.objectweb.asm.ClassWriter
+import org.objectweb.asm.Opcodes
+import org.objectweb.asm.tree.ClassNode
+import org.objectweb.asm.tree.FieldInsnNode
+import org.objectweb.asm.tree.MethodInsnNode
+import org.objectweb.asm.tree.MethodNode
+
+class SetDriverMethodVisitorTest {
+
+ @get:Rule val tmpDir = TemporaryFolder()
+
+ @Test
+ fun `wraps the driver parameter at the start of Room 2_x Builder setDriver`() {
+ assertSetDriverWrappedOnce("androidx.room.RoomDatabase\$Builder")
+ }
+
+ @Test
+ fun `wraps the driver parameter at the start of Room 3_x Builder setDriver`() {
+ assertSetDriverWrappedOnce("androidx.room3.RoomDatabase\$Builder")
+ }
+
+ private fun assertSetDriverWrappedOnce(className: String) {
+ val instrumentedBytes = instrument(className)
+ val setDriverMethod = findSetDriverMethod(instrumentedBytes)
+
+ assertEquals(
+ 1,
+ SQLiteDriverBytecodeTestUtil.countWrapCalls(setDriverMethod),
+ "setDriver should contain exactly one wrap",
+ )
+ assertTrue(
+ wrapPrecedesOriginalBody(setDriverMethod),
+ "SentrySQLiteDriver.create() must run before the original setDriver body",
+ )
+ }
+
+ private fun instrument(className: String): ByteArray {
+ val bytes = SQLiteDriverBytecodeTestUtil.loadRoomBuilderFixture(className)
+ val classReader = ClassReader(bytes)
+ val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
+ val classVisitor =
+ AndroidXSQLiteDriver()
+ .getVisitor(
+ TestClassContext(className),
+ Opcodes.ASM9,
+ classWriter,
+ parameters = TestSpanAddingParameters(inMemoryDir = tmpDir.root),
+ )
+ classReader.accept(classVisitor, ClassReader.SKIP_FRAMES)
+ return classWriter.toByteArray()
+ }
+
+ private fun findSetDriverMethod(bytes: ByteArray): MethodNode {
+ val classNode = ClassNode().also { ClassReader(bytes).accept(it, 0) }
+ return classNode.methods.first {
+ it.name == SetDriverMethodInstrumentable.SET_DRIVER &&
+ SQLiteDriverBytecodeTestUtil.isSetDriverDescriptor(it.desc)
+ }
+ }
+
+ private fun wrapPrecedesOriginalBody(method: MethodNode): Boolean {
+ val realInsns = method.instructions.toArray().filter { it.opcode >= 0 }
+ val wrapIndex =
+ realInsns.indexOfFirst { it is MethodInsnNode && SQLiteDriverBytecodeTestUtil.isWrapCall(it) }
+ assertTrue(wrapIndex >= 0, "setDriver has no SentrySQLiteDriver.create call")
+ val firstOriginalBodyIndex =
+ realInsns.indexOfFirst {
+ (it is MethodInsnNode && !SQLiteDriverBytecodeTestUtil.isWrapCall(it)) ||
+ it is FieldInsnNode
+ }
+ assertTrue(firstOriginalBodyIndex >= 0, "setDriver fixture has no recognizable original body")
+ return wrapIndex < firstOriginalBodyIndex
+ }
+}
diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt
index 50c8c5b0e..fb444b2bc 100644
--- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt
+++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt
@@ -4,6 +4,7 @@ import io.sentry.BuildConfig
import io.sentry.android.gradle.extensions.InstrumentationFeature
import io.sentry.android.gradle.util.AgpVersions
import io.sentry.android.gradle.util.SemVer
+import io.sentry.android.gradle.util.SentryVersions
import io.sentry.android.gradle.verifyDebugMetaPropertiesNotInApk
import io.sentry.android.gradle.verifyDependenciesReportAndroid
import io.sentry.android.gradle.verifyIntegrationList
@@ -16,6 +17,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
+import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.TaskOutcome
import org.gradle.util.GradleVersion
import org.hamcrest.CoreMatchers.`is`
@@ -529,6 +531,82 @@ class SentryPluginTest :
}
}
+ @Test
+ fun `applies sqliteDriver instrumentable when sentry gate passes with room2`() {
+ val build =
+ buildDatabaseInstrumentation(SQLITE, ROOM2_AT_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_DRIVER)
+
+ assertInstrumentableChain(
+ build,
+ "AndroidXSQLiteOpenHelper",
+ "AndroidXSQLiteDriver",
+ "AndroidXRoomDao",
+ )
+ }
+
+ @Test
+ fun `applies sqliteDriver instrumentable when sentry gate passes with room3`() {
+ val build =
+ buildDatabaseInstrumentation(SQLITE, ROOM3_AT_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_DRIVER)
+
+ assertInstrumentableChain(
+ build,
+ "AndroidXSQLiteOpenHelper",
+ "AndroidXSQLiteDriver",
+ "AndroidXRoomDao",
+ )
+ }
+
+ @Test
+ fun `does not apply sqliteDriver instrumentable when sentry gate fails without room on classpath`() {
+ val build = buildDatabaseInstrumentation(SQLITE, SENTRY_ANDROID_SQLITE_OPEN_HELPER)
+
+ assertInstrumentableChain(build, "AndroidXSQLiteOpenHelper", "AndroidXRoomDao")
+ }
+
+ @Test
+ fun `does not apply sqliteDriver instrumentable when sentry gate fails with room2`() {
+ val build =
+ buildDatabaseInstrumentation(SQLITE, ROOM2_AT_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_OPEN_HELPER)
+
+ assertInstrumentableChain(build, "AndroidXSQLiteOpenHelper", "AndroidXRoomDao")
+ }
+
+ @Test
+ fun `does not apply sqliteDriver instrumentable when sentry gate fails with room3`() {
+ val build =
+ buildDatabaseInstrumentation(SQLITE, ROOM3_AT_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_OPEN_HELPER)
+
+ assertInstrumentableChain(build, "AndroidXSQLiteOpenHelper", "AndroidXRoomDao")
+ }
+
+ @Test
+ fun `applies sqliteDriver instrumentable when sentry gate passes without room on classpath`() {
+ val build = buildDatabaseInstrumentation(SQLITE, SENTRY_ANDROID_SQLITE_DRIVER)
+
+ assertInstrumentableChain(
+ build,
+ "AndroidXSQLiteOpenHelper",
+ "AndroidXSQLiteDriver",
+ "AndroidXRoomDao",
+ )
+ }
+
+ @Test
+ fun `applies sqliteDriver instrumentable when sentry gate passes with room below 2_7`() {
+ val build =
+ buildDatabaseInstrumentation(SQLITE, ROOM2_BELOW_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_DRIVER)
+
+ assertInstrumentableChain(
+ build,
+ "AndroidXSQLiteOpenHelper",
+ "AndroidXSQLiteDriver",
+ "AndroidXRoomDao",
+ )
+ }
+
+ // Database path when sentry-android-sqlite is absent (old AndroidXSQLiteDatabase/Statement).
+ // Orthogonal to the SQLiteDriver gate matrix above.
@Test
fun `apply old Database instrumentable when app does not depend on sentry-android-sqlite`() {
applyTracingInstrumentation(
@@ -1106,6 +1184,7 @@ class SentryPluginTest :
excludes: Set = emptySet(),
sdkVersion: String = "7.1.0",
forceInstrumentDependencies: Boolean = true,
+ minSdk: Int? = null,
) {
appBuildFile.appendText(
// language=Groovy
@@ -1131,8 +1210,57 @@ class SentryPluginTest :
excludes = ["${excludes.joinToString()}"]
}
}
+ ${
+ minSdk?.let {
+ """
+ android {
+ defaultConfig {
+ minSdkVersion $it
+ }
+ }
+ """
+ } ?: ""
+ }
"""
.trimIndent()
)
}
+
+ private fun buildDatabaseInstrumentation(vararg dependencies: String): BuildResult {
+ applyTracingInstrumentation(
+ features = setOf(InstrumentationFeature.DATABASE),
+ dependencies = dependencies.toSet(),
+ appStart = false,
+ logcat = false,
+ minSdk = DRIVER_PATH_MIN_SDK,
+ )
+ return runner.appendArguments(":app:assembleDebug", "--info").build()
+ }
+
+ private fun assertInstrumentableChain(build: BuildResult, vararg expected: String) {
+ assertEquals(expected.toList(), instrumentables(build))
+ }
+
+ private fun instrumentables(build: BuildResult): List {
+ val line =
+ build.output.lines().first {
+ it.contains("[sentry] Instrumentable: ChainedInstrumentable(instrumentables=")
+ }
+ val prefix = "ChainedInstrumentable(instrumentables="
+ val start = line.indexOf(prefix) + prefix.length
+ val end = line.lastIndexOf(')')
+ return line.substring(start, end).split(", ").filter { it.isNotEmpty() }
+ }
+
+ companion object {
+ private const val SQLITE = "androidx.sqlite:sqlite:2.6.2"
+ private const val SENTRY_ANDROID_SQLITE_OPEN_HELPER = "io.sentry:sentry-android-sqlite:6.21.0"
+ private val SENTRY_ANDROID_SQLITE_DRIVER =
+ "io.sentry:sentry-android-sqlite:${SentryVersions.VERSION_SQLITE_DRIVER}"
+ private const val ROOM2_AT_DRIVER_FLOOR = "androidx.room:room-runtime:2.7.0"
+ private const val ROOM2_BELOW_DRIVER_FLOOR = "androidx.room:room-runtime:2.6.1"
+ private const val ROOM3_AT_DRIVER_FLOOR = "androidx.room3:room3-runtime:3.0.0-alpha06"
+ /** androidx.sqlite 2.6.x and room3-runtime both require minSdk 23 in the test fixture. */
+ private const val DRIVER_PATH_MIN_SDK = 23
+ }
}
diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesServiceTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesServiceTest.kt
new file mode 100644
index 000000000..662bbce5e
--- /dev/null
+++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesServiceTest.kt
@@ -0,0 +1,168 @@
+package io.sentry.android.gradle.util
+
+import io.sentry.android.gradle.extensions.InstrumentationFeature
+import io.sentry.android.gradle.services.SentryModulesService
+import java.io.File
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.gradle.api.artifacts.ModuleIdentifier
+import org.gradle.testfixtures.ProjectBuilder
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class SentryModulesServiceTest {
+
+ class Fixture {
+
+ fun getSut(
+ tmpDir: File,
+ features: Set = emptySet(),
+ sentryModules: Map = emptyMap(),
+ ): SentryModulesService {
+ val fakeProject = ProjectBuilder.builder().withProjectDir(tmpDir).build()
+
+ val featureProvider = fakeProject.provider { features }
+ val logcatEnabled = fakeProject.provider { true }
+ val sourceContextEnabled = fakeProject.provider { false }
+ val dexguardEnabled = fakeProject.provider { false }
+ val appStartEnabled = fakeProject.provider { false }
+
+ val serviceProvider =
+ SentryModulesService.register(
+ fakeProject,
+ featureProvider,
+ logcatEnabled,
+ sourceContextEnabled,
+ dexguardEnabled,
+ appStartEnabled,
+ )
+ val service = serviceProvider.get()
+ service.sentryModules = sentryModules
+ return service
+ }
+ }
+
+ @get:Rule val testProjectDir = TemporaryFolder()
+
+ private val fixture = Fixture()
+
+ private fun sqliteDriverSentryModules(): Map =
+ mapOf(SentryModules.SENTRY_ANDROID_SQLITE to SentryVersions.VERSION_SQLITE_DRIVER)
+
+ @Test
+ fun `isSQLiteDriverInstrEnabled is true when sentry-android-sqlite meets threshold and DATABASE is enabled`() {
+ val service =
+ fixture.getSut(
+ tmpDir = testProjectDir.root,
+ features = setOf(InstrumentationFeature.DATABASE),
+ sentryModules = sqliteDriverSentryModules(),
+ )
+
+ assertTrue(service.isSQLiteDriverInstrEnabled())
+ }
+
+ @Test
+ fun `isSQLiteDriverInstrEnabled is false when sentry-android-sqlite is absent from classpath`() {
+ val service =
+ fixture.getSut(
+ tmpDir = testProjectDir.root,
+ features = setOf(InstrumentationFeature.DATABASE),
+ sentryModules = emptyMap(),
+ )
+
+ assertFalse(service.isSQLiteDriverInstrEnabled())
+ }
+
+ @Test
+ fun `isSQLiteDriverInstrEnabled is false when sentry-android-sqlite is just below VERSION_SQLITE_DRIVER`() {
+ val belowThreshold =
+ SemVer(
+ SentryVersions.VERSION_SQLITE_DRIVER.major,
+ SentryVersions.VERSION_SQLITE_DRIVER.minor - 1,
+ 99,
+ )
+ val service =
+ fixture.getSut(
+ tmpDir = testProjectDir.root,
+ features = setOf(InstrumentationFeature.DATABASE),
+ sentryModules = mapOf(SentryModules.SENTRY_ANDROID_SQLITE to belowThreshold),
+ )
+
+ assertFalse(service.isSQLiteDriverInstrEnabled())
+ }
+
+ @Test
+ fun `isSQLiteDriverInstrEnabled is false when sentry-android-sqlite is one minor below VERSION_SQLITE_DRIVER`() {
+ val belowThreshold =
+ SemVer(
+ SentryVersions.VERSION_SQLITE_DRIVER.major,
+ SentryVersions.VERSION_SQLITE_DRIVER.minor - 1,
+ SentryVersions.VERSION_SQLITE_DRIVER.patch,
+ )
+ val service =
+ fixture.getSut(
+ tmpDir = testProjectDir.root,
+ features = setOf(InstrumentationFeature.DATABASE),
+ sentryModules = mapOf(SentryModules.SENTRY_ANDROID_SQLITE to belowThreshold),
+ )
+
+ assertFalse(service.isSQLiteDriverInstrEnabled())
+ }
+
+ @Test
+ fun `isSQLiteDriverInstrEnabled is false when DATABASE is disabled`() {
+ val service =
+ fixture.getSut(
+ tmpDir = testProjectDir.root,
+ features = emptySet(),
+ sentryModules = sqliteDriverSentryModules(),
+ )
+
+ assertFalse(service.isSQLiteDriverInstrEnabled())
+ }
+
+ @Test
+ fun `VERSION_SQLITE_DRIVER is greater than or equal to VERSION_SQLITE`() {
+ // Gating relies on the presence of the open helper whenever the driver is present: both
+ // instrumentables fire together and we rely on SentrySQLiteDriver.create to dedup the
+ // SupportSQLiteDriver bridge case.
+ assertTrue(SentryVersions.VERSION_SQLITE_DRIVER >= SentryVersions.VERSION_SQLITE)
+ }
+
+ @Test
+ fun `between VERSION_SQLITE and VERSION_SQLITE_DRIVER only the open-helper path is on`() {
+ val service =
+ fixture.getSut(
+ tmpDir = testProjectDir.root,
+ features = setOf(InstrumentationFeature.DATABASE),
+ sentryModules =
+ mapOf(
+ SentryModules.SENTRY_ANDROID_SQLITE to SentryVersions.VERSION_SQLITE,
+ SentryModules.SENTRY_ANDROID_CORE to SentryVersions.VERSION_PERFORMANCE,
+ ),
+ )
+
+ assertTrue(service.isNewDatabaseInstrEnabled())
+ assertFalse(service.isSQLiteDriverInstrEnabled())
+ assertFalse(service.isOldDatabaseInstrEnabled())
+ }
+
+ @Test
+ fun `at VERSION_SQLITE_DRIVER the open-helper path is also on and the old path is off`() {
+ val service =
+ fixture.getSut(
+ tmpDir = testProjectDir.root,
+ features = setOf(InstrumentationFeature.DATABASE),
+ sentryModules =
+ mapOf(
+ SentryModules.SENTRY_ANDROID_SQLITE to SentryVersions.VERSION_SQLITE_DRIVER,
+ SentryModules.SENTRY_ANDROID_CORE to SentryVersions.VERSION_PERFORMANCE,
+ ),
+ )
+
+ assertTrue(service.isSQLiteDriverInstrEnabled())
+ assertTrue(service.isNewDatabaseInstrEnabled()) // superset relationship
+ assertFalse(service.isOldDatabaseInstrEnabled()) // suppressed by !isNewDatabaseInstrEnabled()
+ }
+}
diff --git a/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase$Builder.class b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase$Builder.class
new file mode 100644
index 000000000..88e8733f1
Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase$Builder.class differ
diff --git a/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase3$Builder.class b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase3$Builder.class
new file mode 100644
index 000000000..57cc73de2
Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase3$Builder.class differ
diff --git a/plugin-build/src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class
new file mode 100644
index 000000000..7d3748a1e
Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class differ