diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf1f4b2a..e5b6b6f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,11 @@ ## Unreleased -### Dependencies +### Features -- Bump mockito-kotlin from `com.nhaarman.mockitokotlin2:2.2.0` to `org.mockito.kotlin:5.4.0` ([#1286](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1286)) +- Auto-instrument SQLiteDriver for Room users ([#1285](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1285)) + - Gated on `sentry-android-sqlite` >= 8.44.0 and the existing `tracingInstrumentation` `DATABASE` feature + - 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) ### Fixes @@ -12,6 +14,10 @@ - This fixed the issue where sentry-cli could not be found (`A problem occurred starting process 'command ../sentry-cliXXX.exe'`) - Defer the telemetry default-org lookup to execution time so the configuration cache no longer re-runs `sentry-cli` on every build ([#1263](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1263)) +### Dependencies + +- Bump mockito-kotlin from `com.nhaarman.mockitokotlin2:2.2.0` to `org.mockito.kotlin:5.4.0` ([#1286](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1286)) + ## 6.10.0 ### Features diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 75831f373..4693b4e53 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 38232b324..117b0007a 100644 --- a/plugin-build/build.gradle.kts +++ b/plugin-build/build.gradle.kts @@ -55,12 +55,17 @@ dependencies { testImplementation(libs.asmCommons) // we need these dependencies for tests, because the bytecode verifier also analyzes superclasses + testImplementationAar(libs.roomRuntimeAndroid) + testImplementationAar(libs.room3RuntimeAndroid) + testImplementationAar(libs.sentryAndroid) + testImplementationAar(libs.sentryAndroidOkhttp) testImplementationAar(libs.sqlite) testImplementationAar(libs.sqliteFramework) - testRuntimeOnly(files(androidSdkPath)) - testImplementationAar(libs.sentryAndroid) + + testImplementation(libs.sample.coroutines.core) testImplementation(libs.sentryOkhttp) - testImplementationAar(libs.sentryAndroidOkhttp) + + testRuntimeOnly(files(androidSdkPath)) // Needed to read contents from APK/Source Bundles testImplementation(libs.arscLib) 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..2ff69cd0a 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 db open helper or + * driver 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..f3872b8a9 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriver.kt @@ -0,0 +1,90 @@ +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 database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(AndroidSQLiteDriver()) + * .build() + * ``` + * + * becomes: + * ```kotlin + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver())) + * .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..1dfdf62ff --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitor.kt @@ -0,0 +1,35 @@ +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() { + loadArg(0) + invokeStatic(Type.getType(SENTRY_SQLITE_DRIVER_TYPE), Method(CREATE, SENTRY_CREATE_DESCRIPTOR)) + storeArg(0) + } + + companion object { + internal const val CREATE = "create" + 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..83101a6fa 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. + * + * Room version is not gated here: Room < 2.7 has no matching `setDriver` method, so + * instrumentation is a no-op. + */ + 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..d5ec03683 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, 44, 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..18d5de702 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,11 +17,13 @@ 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` import org.junit.Assert.assertThrows import org.junit.Assume.assumeThat +import org.junit.Ignore import org.junit.Test class SentryPluginTest : @@ -529,6 +532,86 @@ class SentryPluginTest : } } + @Ignore(SQLITE_DRIVER_IGNORE_REASON) + @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", + ) + } + + @Ignore(SQLITE_DRIVER_IGNORE_REASON) + @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") + } + + @Ignore(SQLITE_DRIVER_IGNORE_REASON) + @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", + ) + } + + @Ignore(SQLITE_DRIVER_IGNORE_REASON) + @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 +1189,7 @@ class SentryPluginTest : excludes: Set = emptySet(), sdkVersion: String = "7.1.0", forceInstrumentDependencies: Boolean = true, + minSdk: Int? = null, ) { appBuildFile.appendText( // language=Groovy @@ -1131,8 +1215,60 @@ 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_DRIVER_IGNORE_REASON = + "Placeholder version VERSION_SQLITE_DRIVER not yet on Maven" + + 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..6ada4e95d --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesServiceTest.kt @@ -0,0 +1,150 @@ +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 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/kotlin/io/sentry/sqlite/SentrySQLiteDriver.kt b/plugin-build/src/test/kotlin/io/sentry/sqlite/SentrySQLiteDriver.kt new file mode 100644 index 000000000..39140f805 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -0,0 +1,14 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteDriver + +/** + * Minimal stub of `io.sentry.sqlite.SentrySQLiteDriver` so ASM can resolve the `INVOKESTATIC + * io/sentry/sqlite/SentrySQLiteDriver.create` emitted by [SetDriverMethodVisitor]. The `@JvmStatic + * create(SQLiteDriver)` shape here mirrors the SDK contract the visitor depends on. + */ +class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : SQLiteDriver { + companion object { + @JvmStatic fun create(delegate: SQLiteDriver): SQLiteDriver = SentrySQLiteDriver(delegate) + } +} 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