Skip to content

Commit f64ad3f

Browse files
committed
feat(plugin): Auto-wrap SQLiteDriver with SentrySQLiteDriver for Room users (GRADLE-107)
Adds a new ASM bytecode method visitor that lets us auto-wrap all occurrences of SQLiteDriver with SentrySQLiteDriver whenever the driver is passed to Room.DatabaseBuilder.setDriver(...). For instance: val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") .setDriver(AndroidSQLiteDriver()) .build() becomes: val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver())) .build() The wrapping policy is naive in that every SQLiteDriver passed to setDriver() is wrapped. That's deliberate because SentrySQLiteDriver protects against double-wrapping internally, which lets us keep our visitor implementation simple. Preconditions: 1. InstrumentationFeature.DATABASE is enabled 2. The owning app is using a version of Room that supports SQLiteDriver 3. The owning app is using a version of sentry-android-sqlite that includes SentrySQLiteDriver Coverage: - Auto-wraps SQLiteDriver for all Room users (sole Room access point is via its Room.DatabaseBuilder.setDriver() method). - SQLDelight users don't need driver auto-wrapping (they still use SupportSQLiteOpenHelper, which we already auto-wrap). - The few developers who use SQLiteDriver directly will need to wrap it manually.
1 parent 8360f20 commit f64ad3f

20 files changed

Lines changed: 879 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Auto-wrap SQLiteDriver with SentrySQLiteDriver for Room users ([#1285](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1285))
8+
- Gated on i) `sentry-android-sqlite` >= 8.44.0, ii) `androidx.room:room-runtime` >= 2.7.0-alpha01 or `androidx.room3:room3-runtime` >= 3.0.0-alpha01, and iii) the existing `tracingInstrumentation` `DATABASE` feature
9+
- For users of the `androidx.sqlite.driver.SupportSQLiteDriver` bridge, the `SupportSQLiteOpenHelper` consumed by the bridge continues to be auto-wrapped as before rather than the bridge itself being wrapped
10+
511
### Fixes
612

713
- 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))

plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,19 @@ open class TracingInstrumentationExtension @Inject constructor(objects: ObjectFa
7575

7676
enum class InstrumentationFeature(val integrationName: String) {
7777
/**
78-
* When enabled the SDK will create spans for any CRUD operation performed by
79-
* 'androidx.sqlite.db.SupportSQLiteOpenHelper' and 'androidx.room'. This feature uses bytecode
80-
* manipulation.
78+
* When enabled the SDK will create spans for database operations at two levels:
79+
*
80+
* **SQL execution** (`db.sql.query` spans): wraps the low-level driver so each individual SQL
81+
* statement produces a span. Two mutually exclusive paths:
82+
* - `androidx.sqlite.db.SupportSQLiteOpenHelper` via any `SupportSQLiteOpenHelper.Factory`
83+
* (open-helper path)
84+
* - `androidx.sqlite.SQLiteDriver` via `RoomDatabase.Builder.setDriver` (driver path)
85+
*
86+
* **DAO method** (`db.sql.room` spans): wraps each public method on Room's generated `@Dao`
87+
* `_Impl` classes, measuring the full DAO call end-to-end (transaction management, query
88+
* execution, and cursor processing). Pre-Room 2.7 only.
89+
*
90+
* This feature uses bytecode manipulation.
8191
*/
8292
DATABASE("DatabaseInstrumentation"),
8393

plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import io.sentry.android.gradle.instrumentation.androidx.compose.ComposeNavigati
1010
import io.sentry.android.gradle.instrumentation.androidx.room.AndroidXRoomDao
1111
import io.sentry.android.gradle.instrumentation.androidx.sqlite.AndroidXSQLiteOpenHelper
1212
import io.sentry.android.gradle.instrumentation.androidx.sqlite.database.AndroidXSQLiteDatabase
13+
import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.AndroidXSQLiteDriver
1314
import io.sentry.android.gradle.instrumentation.androidx.sqlite.statement.AndroidXSQLiteStatement
1415
import io.sentry.android.gradle.instrumentation.appstart.Application
1516
import io.sentry.android.gradle.instrumentation.appstart.ContentProvider
@@ -90,6 +91,7 @@ abstract class SpanAddingClassVisitorFactory :
9091
ChainedInstrumentable(
9192
listOfNotNull(
9293
AndroidXSQLiteOpenHelper().takeIf { sentryModulesService.isNewDatabaseInstrEnabled() },
94+
AndroidXSQLiteDriver().takeIf { sentryModulesService.isSQLiteDriverInstrEnabled() },
9395
AndroidXSQLiteDatabase().takeIf { sentryModulesService.isOldDatabaseInstrEnabled() },
9496
AndroidXSQLiteStatement(androidXSqliteFrameWorkVersion).takeIf {
9597
sentryModulesService.isOldDatabaseInstrEnabled()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver
2+
3+
import com.android.build.api.instrumentation.ClassContext
4+
import io.sentry.android.gradle.instrumentation.ClassInstrumentable
5+
import io.sentry.android.gradle.instrumentation.CommonClassVisitor
6+
import io.sentry.android.gradle.instrumentation.MethodContext
7+
import io.sentry.android.gradle.instrumentation.MethodInstrumentable
8+
import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory
9+
import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor.SetDriverMethodVisitor
10+
import org.objectweb.asm.ClassVisitor
11+
import org.objectweb.asm.MethodVisitor
12+
13+
/**
14+
* Auto-instruments `SQLiteDriver` for all Room users by wrapping the driver parameter in
15+
* `RoomDatabase.Builder.setDriver(SQLiteDriver)`.
16+
*
17+
* Note: As of this writing, SQLDelight doesn't support `SQLiteDriver`
18+
* ([link](https://github.com/sqldelight/sqldelight/issues/6072)), and developers who use the driver
19+
* directly are expected to wrap it themselves.
20+
*
21+
* The SDK protects against duplicate wrappings, allowing the visitor to wrap the driver
22+
* unconditionally.
23+
*/
24+
class AndroidXSQLiteDriver : ClassInstrumentable {
25+
26+
override fun getVisitor(
27+
instrumentableContext: ClassContext,
28+
apiVersion: Int,
29+
originalVisitor: ClassVisitor,
30+
parameters: SpanAddingClassVisitorFactory.SpanAddingParameters,
31+
): ClassVisitor {
32+
val currentClassName = instrumentableContext.currentClassData.className
33+
34+
return CommonClassVisitor(
35+
apiVersion = apiVersion,
36+
classVisitor = originalVisitor,
37+
className = currentClassName.substringAfterLast('.'),
38+
methodInstrumentables = listOf(SetDriverMethodInstrumentable()),
39+
parameters = parameters,
40+
)
41+
}
42+
43+
// Instrument RoomDatabase.Builder.setDriver() in room-runtime and room3 directly.
44+
override fun isInstrumentable(data: ClassContext): Boolean =
45+
data.currentClassData.className in TARGET_CLASSES
46+
47+
companion object {
48+
49+
/** Currently covers Room 2 and Room 3 packages. Update as needed. */
50+
internal val TARGET_CLASSES =
51+
setOf("androidx.room.RoomDatabase\$Builder", "androidx.room3.RoomDatabase\$Builder")
52+
}
53+
}
54+
55+
class SetDriverMethodInstrumentable : MethodInstrumentable {
56+
57+
override val fqName: String
58+
get() = SET_DRIVER
59+
60+
override fun getVisitor(
61+
instrumentableContext: MethodContext,
62+
apiVersion: Int,
63+
originalVisitor: MethodVisitor,
64+
parameters: SpanAddingClassVisitorFactory.SpanAddingParameters,
65+
): MethodVisitor = SetDriverMethodVisitor(apiVersion, originalVisitor, instrumentableContext)
66+
67+
override fun isInstrumentable(data: MethodContext): Boolean =
68+
data.name == SET_DRIVER && data.descriptor?.startsWith(SET_DRIVER_DESCRIPTOR_PREFIX) == true
69+
70+
companion object {
71+
private const val SET_DRIVER = "setDriver"
72+
private const val SET_DRIVER_DESCRIPTOR_PREFIX = "(Landroidx/sqlite/SQLiteDriver;)"
73+
}
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor
2+
3+
import io.sentry.android.gradle.instrumentation.MethodContext
4+
import org.objectweb.asm.MethodVisitor
5+
import org.objectweb.asm.Type
6+
import org.objectweb.asm.commons.AdviceAdapter
7+
import org.objectweb.asm.commons.Method
8+
9+
class SetDriverMethodVisitor(
10+
apiVersion: Int,
11+
originalVisitor: MethodVisitor,
12+
instrumentableContext: MethodContext,
13+
) :
14+
AdviceAdapter(
15+
apiVersion,
16+
originalVisitor,
17+
instrumentableContext.access,
18+
instrumentableContext.name,
19+
instrumentableContext.descriptor,
20+
) {
21+
22+
override fun onMethodEnter() {
23+
loadArg(0)
24+
invokeStatic(Type.getType(SENTRY_SQLITE_DRIVER_TYPE), Method(CREATE, SENTRY_CREATE_DESCRIPTOR))
25+
storeArg(0)
26+
}
27+
28+
companion object {
29+
private const val SENTRY_SQLITE_DRIVER_TYPE = "Lio/sentry/sqlite/SentrySQLiteDriver;"
30+
private const val CREATE = "create"
31+
private const val SENTRY_CREATE_DESCRIPTOR =
32+
"(Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver;"
33+
}
34+
}

plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ package io.sentry.android.gradle.services
44

55
import com.android.build.gradle.internal.utils.setDisallowChanges
66
import io.sentry.android.gradle.extensions.InstrumentationFeature
7+
import io.sentry.android.gradle.util.ExternalModules
8+
import io.sentry.android.gradle.util.ExternalVersions
79
import io.sentry.android.gradle.util.SemVer
810
import io.sentry.android.gradle.util.SentryModules
911
import io.sentry.android.gradle.util.SentryVersions
@@ -52,6 +54,10 @@ abstract class SentryModulesService :
5254
features.add("DexGuard")
5355
}
5456

57+
if (isSQLiteDriverInstrEnabled()) {
58+
features.add("SQLiteDriver")
59+
}
60+
5561
return project.provider { features }
5662
}
5763

@@ -72,6 +78,30 @@ abstract class SentryModulesService :
7278
sentryModules.isAtLeast(SentryModules.SENTRY_ANDROID_SQLITE, SentryVersions.VERSION_SQLITE) &&
7379
parameters.features.get().contains(InstrumentationFeature.DATABASE)
7480

81+
fun isSQLiteDriverInstrEnabled(): Boolean =
82+
isSQLiteDriverSentryGateEnabled() && isSQLiteDriverRoomGateEnabled()
83+
84+
/**
85+
* Returns true if the owning app uses a version of sentry-android-sqlite that contains the
86+
* `SentrySQLiteDriver`.
87+
*/
88+
private fun isSQLiteDriverSentryGateEnabled(): Boolean =
89+
sentryModules.isAtLeast(
90+
SentryModules.SENTRY_ANDROID_SQLITE,
91+
SentryVersions.VERSION_SQLITE_DRIVER,
92+
) && parameters.features.get().contains(InstrumentationFeature.DATABASE)
93+
94+
/** Returns true if the owning app uses on a version of Room that supports `SQLiteDriver`. */
95+
private fun isSQLiteDriverRoomGateEnabled(): Boolean =
96+
externalModules.isAtLeastMinor(
97+
ExternalModules.ROOM2_RUNTIME,
98+
ExternalVersions.ROOM2_SQLITE_DRIVER_VERSION,
99+
) ||
100+
externalModules.isAtLeastMinor(
101+
ExternalModules.ROOM3_RUNTIME,
102+
ExternalVersions.ROOM3_SQLITE_DRIVER_VERSION,
103+
)
104+
75105
fun isOldDatabaseInstrEnabled(): Boolean =
76106
!isNewDatabaseInstrEnabled() &&
77107
sentryModules.isAtLeast(
@@ -121,6 +151,21 @@ abstract class SentryModulesService :
121151
minVersion: SemVer,
122152
): Boolean = getOrDefault(module, SentryVersions.VERSION_DEFAULT) >= minVersion
123153

154+
/**
155+
* External-library gate on major.minor only, irrespective of patch or pre-release. Pre-releases
156+
* on the floor line pass (e.g. Room `2.7.0-alpha12` satisfies a `2.7.0` floor).
157+
*
158+
* Not equivalent to `>=`, as full [SemVer] ordering ranks pre-releases below the release.
159+
*/
160+
private fun Map<ModuleIdentifier, SemVer>.isAtLeastMinor(
161+
module: ModuleIdentifier,
162+
minVersion: SemVer,
163+
): Boolean {
164+
val version = getOrDefault(module, SentryVersions.VERSION_DEFAULT)
165+
return version.major > minVersion.major ||
166+
(version.major == minVersion.major && version.minor >= minVersion.minor)
167+
}
168+
124169
companion object {
125170
fun register(
126171
project: Project,

plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,21 @@ internal object SentryVersions {
3333
internal val VERSION_LOGCAT = SemVer(6, 17, 0)
3434
internal val VERSION_APP_START = SemVer(7, 1, 0)
3535
internal val VERSION_SQLITE = SemVer(6, 21, 0)
36+
internal val VERSION_SQLITE_DRIVER = SemVer(8, 44, 0)
3637
internal val VERSION_ANDROID_OKHTTP_LISTENER = SemVer(6, 20, 0)
3738
internal val VERSION_OKHTTP = SemVer(7, 0, 0)
3839
}
3940

41+
internal object ExternalVersions {
42+
// Room 2.7.0 introduced RoomDatabase.Builder.setDriver(SQLiteDriver) in the androidx.room
43+
// package.
44+
internal val ROOM2_SQLITE_DRIVER_VERSION = SemVer(2, 7, 0)
45+
46+
// Room 3.0 moved the RoomDatabase.Builder.setDriver(SQLiteDriver) to the androidx.room3
47+
// package.
48+
internal val ROOM3_SQLITE_DRIVER_VERSION = SemVer(3, 0, 0)
49+
}
50+
4051
internal object SentryModules {
4152
internal val SENTRY = DefaultModuleIdentifier.newId("io.sentry", "sentry")
4253
internal val SENTRY_ANDROID = DefaultModuleIdentifier.newId("io.sentry", "sentry-android")
@@ -80,3 +91,8 @@ internal object SentryModules {
8091
internal val SENTRY_OPENTELEMETRY_AGENTLESS_SPRING =
8192
DefaultModuleIdentifier.newId("io.sentry", "sentry-opentelemetry-agentless-spring")
8293
}
94+
95+
internal object ExternalModules {
96+
internal val ROOM2_RUNTIME = DefaultModuleIdentifier.newId("androidx.room", "room-runtime")
97+
internal val ROOM3_RUNTIME = DefaultModuleIdentifier.newId("androidx.room3", "room3-runtime")
98+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package androidx.sqlite
2+
3+
interface SQLiteDriver

plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import io.sentry.android.gradle.instrumentation.androidx.compose.ComposeNavigati
55
import io.sentry.android.gradle.instrumentation.androidx.room.AndroidXRoomDao
66
import io.sentry.android.gradle.instrumentation.androidx.sqlite.AndroidXSQLiteOpenHelper
77
import io.sentry.android.gradle.instrumentation.androidx.sqlite.database.AndroidXSQLiteDatabase
8+
import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.AndroidXSQLiteDriver
89
import io.sentry.android.gradle.instrumentation.androidx.sqlite.statement.AndroidXSQLiteStatement
910
import io.sentry.android.gradle.instrumentation.appstart.Application
1011
import io.sentry.android.gradle.instrumentation.appstart.ContentProvider
@@ -122,6 +123,18 @@ class VisitorTest(
122123
AndroidXSQLiteStatement(SemVer(2, 3, 0)),
123124
null,
124125
),
126+
arrayOf(
127+
"androidxRoom",
128+
"RoomDatabase\$Builder",
129+
AndroidXSQLiteDriver(),
130+
TestClassContext("androidx.room.RoomDatabase\$Builder"),
131+
),
132+
arrayOf(
133+
"androidxRoom",
134+
"RoomDatabase3\$Builder",
135+
AndroidXSQLiteDriver(),
136+
TestClassContext("androidx.room3.RoomDatabase\$Builder"),
137+
),
125138
roomDaoTestParameters("DeleteAndReturnUnit"),
126139
roomDaoTestParameters("InsertAndReturnLong"),
127140
roomDaoTestParameters("InsertAndReturnUnit"),
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
@file:Suppress("UnstableApiUsage")
2+
3+
package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver
4+
5+
import io.sentry.android.gradle.instrumentation.ChainedInstrumentable
6+
import io.sentry.android.gradle.instrumentation.fakes.TestClassContext
7+
import io.sentry.android.gradle.instrumentation.fakes.TestSpanAddingParameters
8+
import java.io.FileInputStream
9+
import kotlin.test.assertEquals
10+
import kotlin.test.assertFalse
11+
import kotlin.test.assertTrue
12+
import org.junit.Rule
13+
import org.junit.Test
14+
import org.junit.rules.TemporaryFolder
15+
import org.objectweb.asm.ClassReader
16+
import org.objectweb.asm.ClassWriter
17+
import org.objectweb.asm.Opcodes
18+
19+
class AndroidXSQLiteDriverTest {
20+
21+
@get:Rule val tmpDir = TemporaryFolder()
22+
23+
private val instrumentable = AndroidXSQLiteDriver()
24+
25+
@Test
26+
fun `isInstrumentable returns true for RoomDatabase Builder classes`() {
27+
assertTrue(
28+
instrumentable.isInstrumentable(TestClassContext("androidx.room.RoomDatabase\$Builder"))
29+
)
30+
assertTrue(
31+
instrumentable.isInstrumentable(TestClassContext("androidx.room3.RoomDatabase\$Builder"))
32+
)
33+
}
34+
35+
@Test
36+
fun `isInstrumentable returns false for unrelated classes`() {
37+
assertFalse(instrumentable.isInstrumentable(TestClassContext("com.example.RoomConfig")))
38+
assertFalse(instrumentable.isInstrumentable(TestClassContext("io.sentry.Sentry")))
39+
assertFalse(instrumentable.isInstrumentable(TestClassContext("com.example.FakeSetDriver")))
40+
}
41+
42+
@Test
43+
fun `ChainedInstrumentable does not instrument unrelated classes`() {
44+
val className = "com.example.NoSetDriver"
45+
val originalBytes = loadNoSetDriverFixtureBytes()
46+
val instrumentedBytes = instrumentThroughChain(className, originalBytes)
47+
48+
assertEquals(0, SQLiteDriverBytecodeTestUtil.countWrapCalls(instrumentedBytes))
49+
}
50+
51+
private fun instrumentThroughChain(className: String, bytes: ByteArray): ByteArray {
52+
val classReader = ClassReader(bytes)
53+
val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
54+
val classVisitor =
55+
ChainedInstrumentable(listOf(instrumentable))
56+
.getVisitor(
57+
TestClassContext(className),
58+
Opcodes.ASM9,
59+
classWriter,
60+
parameters = TestSpanAddingParameters(inMemoryDir = tmpDir.root),
61+
)
62+
classReader.accept(classVisitor, ClassReader.SKIP_FRAMES)
63+
return classWriter.toByteArray()
64+
}
65+
66+
private fun loadNoSetDriverFixtureBytes(): ByteArray =
67+
FileInputStream(
68+
"src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class"
69+
)
70+
.use { it.readBytes() }
71+
}

0 commit comments

Comments
 (0)