Skip to content

Commit 3eb7173

Browse files
feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275) (#5466)
feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275) Introduces support for AndroidX's SQLiteDriver via a new SentrySQLiteDriver wrapper. SentrySQLiteDriver automatically creates spans for each SQL statement it executes. Its data scheme closely tracks that of SentrySupportSQLiteOpenHelper, which it's designed to replace. (Span duration is an important exception; see the SentrySQLiteStatement KDoc for more details.) A key motivation behind Google's use of SQLiteDriver with Room 2.7+ was Kotlin Multiplatform support. We're careful to keep the SentrySQLiteDriver KMP-compatible as well, should we one day want to lift it into sentry-kotlin-multiplatform. --- Co-authored-by: Angus Holder <7407345+angusholder@users.noreply.github.com>
1 parent 9d2f4e3 commit 3eb7173

14 files changed

Lines changed: 1144 additions & 36 deletions

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo
9595
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
9696
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" }
9797
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
98-
androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" }
98+
androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" }
9999
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" }
100100
androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" }
101101
async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" }

sentry-android-sqlite/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# sentry-android-sqlite
2+
3+
SQLite instrumentation for AndroidX APIs.
4+
5+
Two instrumentation paths are supported:
6+
7+
- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+.
8+
- **`androidx.sqlite.db.SupportSQLiteOpenHelper`**: Used by SQLDelight and legacy (pre-2.7) Room. Applied automatically by the Sentry Android Gradle Plugin.
9+
10+
To avoid duplicate spans, only one path should be used per database file. Most Room and SQLDelight APIs enforce that division. The exception is Room's `SupportSQLiteDriver`: either the `SupportSQLiteOpenHelper` it consumes should be wrapped or the support driver itself, but never both.
11+
12+
## Package layout
13+
14+
The module is organized as two separate packages:
15+
16+
- **`io.sentry.android.sqlite`**: Android-specific code. Depends on `android.database.*` and/or on `androidx.sqlite.db.*`.
17+
- **`io.sentry.sqlite`**: No Android-specific code. Depends only on multiplatform `androidx.sqlite.*`.
18+
19+
The split anticipates future Kotlin Multiplatform support. The `androidx.sqlite.*` interfaces are defined in KMP's `commonMain` source set and are used by Room in non-JVM environments. Classes in `io.sentry.sqlite` are written against those portable interfaces and are intended to lift cleanly into a KMP `commonMain` source set if/when the `sentry` core gains multiplatform targets.
20+
21+
Note that the module artifact itself (`sentry-android-sqlite`) is currently an Android-only AAR regardless of package layout.

sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt

Lines changed: 8 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,17 @@ package io.sentry.android.sqlite
33
import android.database.CrossProcessCursor
44
import android.database.SQLException
55
import io.sentry.IScopes
6-
import io.sentry.ISpan
7-
import io.sentry.Instrumenter
86
import io.sentry.ScopesAdapter
97
import io.sentry.SentryIntegrationPackageStorage
10-
import io.sentry.SentryStackTraceFactory
11-
import io.sentry.SpanDataConvention
128
import io.sentry.SpanStatus
13-
14-
private const val TRACE_ORIGIN = "auto.db.sqlite"
9+
import io.sentry.sqlite.SQLiteSpanInstrumentation
1510

1611
internal class SQLiteSpanManager(
1712
private val scopes: IScopes = ScopesAdapter.getInstance(),
18-
private val databaseName: String? = null,
13+
databaseName: String? = null,
1914
) {
20-
private val stackTraceFactory = SentryStackTraceFactory(scopes.options)
15+
16+
private val spans = SQLiteSpanInstrumentation.fromDatabaseName(databaseName, scopes)
2117

2218
init {
2319
SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite")
@@ -33,8 +29,8 @@ internal class SQLiteSpanManager(
3329
@Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST")
3430
@Throws(SQLException::class)
3531
fun <T> performSql(sql: String, operation: () -> T): T {
36-
val startTimestamp = scopes.getOptions().dateProvider.now()
37-
var span: ISpan? = null
32+
val startTimestamp = spans.startTimestamp()
33+
3834
return try {
3935
val result = operation()
4036
/*
@@ -45,34 +41,11 @@ internal class SQLiteSpanManager(
4541
if (result is CrossProcessCursor) {
4642
return SentryCrossProcessCursor(result, this, sql) as T
4743
}
48-
span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)
49-
span?.spanContext?.origin = TRACE_ORIGIN
50-
span?.status = SpanStatus.OK
44+
spans.recordSpan(sql, startTimestamp, SpanStatus.OK)
5145
result
5246
} catch (e: Throwable) {
53-
span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)
54-
span?.spanContext?.origin = TRACE_ORIGIN
55-
span?.status = SpanStatus.INTERNAL_ERROR
56-
span?.throwable = e
47+
spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e)
5748
throw e
58-
} finally {
59-
span?.apply {
60-
val isMainThread: Boolean = scopes.options.threadChecker.isMainThread
61-
setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread)
62-
if (isMainThread) {
63-
setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack)
64-
}
65-
// if db name is null, then it's an in-memory database as per
66-
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.kt;l=38-42
67-
if (databaseName != null) {
68-
setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite")
69-
setData(SpanDataConvention.DB_NAME_KEY, databaseName)
70-
} else {
71-
setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory")
72-
}
73-
74-
finish()
75-
}
7649
}
7750
}
7851
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package io.sentry.sqlite
2+
3+
/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for in-memory databases. */
4+
internal const val DB_SYSTEM_IN_MEMORY = "in-memory"
5+
6+
/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for SQLite databases. */
7+
internal const val DB_SYSTEM_SQLITE = "sqlite"
8+
9+
/**
10+
* Sentinel file name that [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open] interprets as an
11+
* in-memory database (see docs
12+
* [here](https://developer.android.com/reference/androidx/sqlite/driver/AndroidSQLiteDriver)).
13+
*/
14+
private const val IN_MEMORY_DB_FILENAME = ":memory:"
15+
16+
/** Path separators matching [File.separatorChar][java.io.File.separatorChar]. */
17+
private val FILE_NAME_PATH_SEPARATORS = charArrayOf('/', '\\')
18+
19+
internal data class DbMetadata(val name: String?, val system: String)
20+
21+
/**
22+
* Returns metadata based on the [fileName] argument passed to
23+
* [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open].
24+
*/
25+
internal fun dbMetadataFromFileName(fileName: String): DbMetadata {
26+
if (fileName == IN_MEMORY_DB_FILENAME) {
27+
return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY)
28+
}
29+
30+
val trimmed = fileName.trimEnd { it in FILE_NAME_PATH_SEPARATORS }
31+
if (trimmed.isEmpty()) {
32+
return DbMetadata(name = null, system = DB_SYSTEM_SQLITE)
33+
}
34+
35+
val index = trimmed.lastIndexOfAny(FILE_NAME_PATH_SEPARATORS)
36+
val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed
37+
return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE)
38+
}
39+
40+
/**
41+
* Returns metadata based on
42+
* [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName].
43+
*/
44+
internal fun dbMetadataFromDatabaseName(databaseName: String?): DbMetadata =
45+
if (databaseName == null) {
46+
DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY)
47+
} else {
48+
DbMetadata(name = databaseName, system = DB_SYSTEM_SQLITE)
49+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package io.sentry.sqlite
2+
3+
import io.sentry.IScopes
4+
import io.sentry.Instrumenter
5+
import io.sentry.ScopesAdapter
6+
import io.sentry.SentryDate
7+
import io.sentry.SentryLongDate
8+
import io.sentry.SentryStackTraceFactory
9+
import io.sentry.SpanDataConvention
10+
import io.sentry.SpanStatus
11+
12+
private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite"
13+
14+
/** Shared span instrumentation for SQLite. */
15+
internal class SQLiteSpanInstrumentation(
16+
private val scopes: IScopes,
17+
private val dbMetadata: DbMetadata,
18+
) {
19+
20+
private val stackTraceFactory = SentryStackTraceFactory(scopes.options)
21+
22+
/**
23+
* Returns a start timestamp for a `db.sql.query` span.
24+
*
25+
* Exposed so callers can capture a wall-clock start before accumulating database time.
26+
* Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace
27+
* timeline, which is less desirable.
28+
*/
29+
fun startTimestamp(): SentryDate = scopes.options.dateProvider.now()
30+
31+
/** Records a `db.sql.query` span from [startTimestamp] to the moment of invocation. */
32+
fun recordSpan(
33+
sql: String,
34+
startTimestamp: SentryDate,
35+
status: SpanStatus,
36+
throwable: Throwable? = null,
37+
) {
38+
recordSpan(sql, startTimestamp, endTimestamp = null, status, throwable)
39+
}
40+
41+
/** Records a `db.sql.query` span from [startTimestamp] to [startTimestamp] + [durationNanos]. */
42+
fun recordSpan(
43+
sql: String,
44+
startTimestamp: SentryDate,
45+
durationNanos: Long,
46+
status: SpanStatus,
47+
throwable: Throwable? = null,
48+
) {
49+
val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos)
50+
recordSpan(sql, startTimestamp, endTimestamp, status, throwable)
51+
}
52+
53+
private fun recordSpan(
54+
sql: String,
55+
startTimestamp: SentryDate,
56+
endTimestamp: SentryDate?,
57+
status: SpanStatus,
58+
throwable: Throwable?,
59+
) {
60+
scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply {
61+
spanContext.origin = SQLITE_TRACE_ORIGIN
62+
throwable?.let { this.throwable = it }
63+
64+
val isMainThread = scopes.options.threadChecker.isMainThread
65+
setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread)
66+
67+
if (isMainThread) {
68+
setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack)
69+
}
70+
71+
dbMetadata.name?.let { setData(SpanDataConvention.DB_NAME_KEY, it) }
72+
setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system)
73+
finish(status, endTimestamp)
74+
}
75+
}
76+
77+
companion object {
78+
79+
/**
80+
* Returns [SQLiteSpanInstrumentation] based on the [fileName] argument passed to
81+
* [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open].
82+
*/
83+
fun fromFileName(
84+
fileName: String,
85+
scopes: IScopes = ScopesAdapter.getInstance(),
86+
): SQLiteSpanInstrumentation =
87+
SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName))
88+
89+
/**
90+
* Returns [SQLiteSpanInstrumentation] based on
91+
* [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName].
92+
*/
93+
fun fromDatabaseName(
94+
databaseName: String?,
95+
scopes: IScopes = ScopesAdapter.getInstance(),
96+
): SQLiteSpanInstrumentation =
97+
SQLiteSpanInstrumentation(scopes, dbMetadataFromDatabaseName(databaseName))
98+
}
99+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.sentry.sqlite
2+
3+
import androidx.sqlite.SQLiteConnection
4+
import androidx.sqlite.SQLiteStatement
5+
6+
internal class SentrySQLiteConnection(
7+
private val delegate: SQLiteConnection,
8+
private val spans: SQLiteSpanInstrumentation,
9+
) : SQLiteConnection by delegate {
10+
11+
override fun prepare(sql: String): SQLiteStatement {
12+
val statement = delegate.prepare(sql)
13+
return statement as? SentrySQLiteStatement ?: SentrySQLiteStatement(statement, spans, sql)
14+
}
15+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package io.sentry.sqlite
2+
3+
import androidx.sqlite.SQLiteConnection
4+
import androidx.sqlite.SQLiteDriver
5+
import io.sentry.ScopesAdapter
6+
import io.sentry.SentryIntegrationPackageStorage
7+
import io.sentry.SentryLevel
8+
9+
/**
10+
* Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes.
11+
*
12+
* Example usage:
13+
* ```
14+
* val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver())
15+
* ```
16+
*
17+
* If you use Room:
18+
* ```
19+
* val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName")
20+
* .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver()))
21+
* .build()
22+
* ```
23+
*
24+
* **Warning:** Do not use [SentrySQLiteDriver] together with
25+
* [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the
26+
* same database file. Both wrappers instrument at different layers and combining them will produce
27+
* duplicate spans.
28+
*
29+
* @param delegate The [SQLiteDriver] instance to delegate calls to.
30+
*/
31+
internal class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) :
32+
SQLiteDriver {
33+
34+
init {
35+
SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver")
36+
}
37+
38+
override val hasConnectionPool: Boolean
39+
get() =
40+
try {
41+
delegate.hasConnectionPool
42+
} catch (_: LinkageError) {
43+
// Delegates on androidx.sqlite < 2.6.0 won't have a hasConnectionPool property.
44+
false
45+
}
46+
47+
@Suppress("TooGenericExceptionCaught")
48+
override fun open(fileName: String): SQLiteConnection {
49+
val connection = delegate.open(fileName)
50+
51+
return try {
52+
val spans = SQLiteSpanInstrumentation.fromFileName(fileName)
53+
// create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping
54+
// the connection.
55+
SentrySQLiteConnection(connection, spans)
56+
} catch (t: Throwable) {
57+
ScopesAdapter.getInstance()
58+
.options
59+
.logger
60+
.log(
61+
SentryLevel.ERROR,
62+
"Failed to instrument SQLite connection; returning uninstrumented connection.",
63+
t,
64+
)
65+
connection
66+
}
67+
}
68+
69+
companion object {
70+
71+
/**
72+
* Wraps the provided delegate in a [SentrySQLiteDriver]. Returns the delegate as-is if already
73+
* wrapped.
74+
*/
75+
@JvmStatic
76+
fun create(delegate: SQLiteDriver): SQLiteDriver =
77+
delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate)
78+
}
79+
}

0 commit comments

Comments
 (0)