Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@

## 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

- Resolve the sentry-cli path as a task input instead of memoizing it in a static field, fixing stale-path build failures when switching branches with the configuration cache enabled ([#1264](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1264))
- 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
Expand Down
5 changes: 4 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions plugin-build/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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)"
}
}
Original file line number Diff line number Diff line change
@@ -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;"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Update version number once we ship the SentrySQLiteDriver in sentry-android.

internal val VERSION_ANDROID_OKHTTP_LISTENER = SemVer(6, 20, 0)
internal val VERSION_OKHTTP = SemVer(7, 0, 0)
}
Expand Down
12 changes: 12 additions & 0 deletions plugin-build/src/test/kotlin/androidx/sqlite/SQLiteDriver.kt
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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() }
}
Loading
Loading