Skip to content

Commit 4c67ea9

Browse files
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, and 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 for Google's using SQLiteDriver with Room 2.7+ was Kotlin Multiplatform support. We've been 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 ca6b6d8 commit 4c67ea9

15 files changed

Lines changed: 1076 additions & 27 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Add `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting AndroidX's `SQLiteDriver` ([#5466](https://github.com/getsentry/sentry-java/pull/5466))
8+
- Automatically generates spans for all SQLite statements
9+
- To use it, pass your `SQLiteDriver` to `SentrySQLiteDriver.create(...)`
10+
- See https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/ for more details, including info about migrating from `SentrySupportSQLiteOpenHelper`
11+
312
## 8.43.0
413

514
### Features

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+
This module provides automatic SQLite query instrumentation for Android.
4+
5+
Two instrumentation paths are supported, matching the two SQLite APIs offered by AndroidX:
6+
7+
- **`androidx.sqlite.SQLiteDriver`** — used by Room 2.7+ via `Room.databaseBuilder(...).setDriver(...)` and by SQLDelight via its AndroidX SQLite driver.
8+
- **`androidx.sqlite.db.SupportSQLiteOpenHelper`** — used by legacy Room via `Room.databaseBuilder(...).openHelperFactory(...)`, or applied automatically by the Sentry Android Gradle plugin.
9+
10+
Please consult the [Sentry Docs](https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/) for usage and migration guidance, as well as how to avoid duplicate spans when using Room's `SupportSQLiteDriver` adapter.
11+
12+
## Package layout
13+
14+
This module is organized as two separate packages:
15+
16+
- **`io.sentry.android.sqlite`**: Android-specific code. Classes here depend on `android.database.*` (e.g., `CrossProcessCursor`, `SQLException`) and/or on `androidx.sqlite.db.*`, the Android-only compatibility layer over the platform's SQLite. The `SentrySupportSQLiteOpenHelper` path and its span helper `SQLiteSpanManager` live here.
17+
- **`io.sentry.sqlite`**: Code whose contract depends only on the multiplatform `androidx.sqlite.*` interfaces (e.g., `SQLiteDriver` and `SQLiteConnection`). `SentrySQLiteDriver` and its span helper `SQLiteSpanRecorder` live here.
18+
19+
The split anticipates the possibility of future Kotlin Multiplatform support. The `androidx.sqlite.*` driver interfaces are defined in the library's `commonMain` source set and are reused by Room across Android, JVM, and native targets. 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. Classes in `io.sentry.android.sqlite` are Android-only by construction and will stay where they are.
20+
21+
Note that the module artifact itself (`sentry-android-sqlite`) is currently an Android-only AAR regardless of package layout.

sentry-android-sqlite/api/sentry-android-sqlite.api

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,14 @@ public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Compan
2121
public final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper;
2222
}
2323

24+
public final class io/sentry/sqlite/SentrySQLiteDriver : androidx/sqlite/SQLiteDriver {
25+
public static final field Companion Lio/sentry/sqlite/SentrySQLiteDriver$Companion;
26+
public synthetic fun <init> (Landroidx/sqlite/SQLiteDriver;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
27+
public static final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver;
28+
public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection;
29+
}
30+
31+
public final class io/sentry/sqlite/SentrySQLiteDriver$Companion {
32+
public final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver;
33+
}
34+

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

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,18 @@ import android.database.CrossProcessCursor
44
import android.database.SQLException
55
import io.sentry.IScopes
66
import io.sentry.ISpan
7-
import io.sentry.Instrumenter
87
import io.sentry.ScopesAdapter
98
import io.sentry.SentryIntegrationPackageStorage
10-
import io.sentry.SentryStackTraceFactory
11-
import io.sentry.SpanDataConvention
129
import io.sentry.SpanStatus
13-
14-
private const val TRACE_ORIGIN = "auto.db.sqlite"
10+
import io.sentry.sqlite.SQLiteSpanHelper
11+
import io.sentry.sqlite.dbMetadataFromDatabaseName
1512

1613
internal class SQLiteSpanManager(
1714
private val scopes: IScopes = ScopesAdapter.getInstance(),
18-
private val databaseName: String? = null,
15+
databaseName: String? = null,
1916
) {
20-
private val stackTraceFactory = SentryStackTraceFactory(scopes.options)
17+
18+
private val spanHelper = SQLiteSpanHelper(scopes, dbMetadataFromDatabaseName(databaseName))
2119

2220
init {
2321
SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite")
@@ -45,33 +43,18 @@ internal class SQLiteSpanManager(
4543
if (result is CrossProcessCursor) {
4644
return SentryCrossProcessCursor(result, this, sql) as T
4745
}
48-
span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)
49-
span?.spanContext?.origin = TRACE_ORIGIN
46+
span = spanHelper.startSpan(sql, startTimestamp)
5047
span?.status = SpanStatus.OK
5148
result
5249
} catch (e: Throwable) {
53-
span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)
54-
span?.spanContext?.origin = TRACE_ORIGIN
50+
span = spanHelper.startSpan(sql, startTimestamp)
5551
span?.status = SpanStatus.INTERNAL_ERROR
5652
span?.throwable = e
5753
throw e
5854
} 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()
55+
span?.let {
56+
spanHelper.applyDataToSpan(it)
57+
it.finish()
7558
}
7659
}
7760
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.sentry.sqlite
2+
3+
/**
4+
* Value associated with [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] for in-memory
5+
* databases.
6+
*/
7+
internal const val DB_SYSTEM_IN_MEMORY = "in-memory"
8+
9+
/**
10+
* Value associated with [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] for SQLite
11+
* databases.
12+
*/
13+
internal const val DB_SYSTEM_SQLITE = "sqlite"
14+
15+
/**
16+
* Sentinel file name that [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open] interprets as an
17+
* in-memory database:
18+
* https://developer.android.com/reference/androidx/sqlite/driver/AndroidSQLiteDriver.
19+
*/
20+
private const val IN_MEMORY_DB_FILENAME = ":memory:"
21+
22+
/** Path separators matching [File.separatorChar][java.io.File.separatorChar]. */
23+
private val FILE_NAME_PATH_SEPARATORS = charArrayOf('/', '\\')
24+
25+
internal data class DbMetadata(val name: String?, val system: String)
26+
27+
/**
28+
* Resolves metadata from the [fileName] argument to
29+
* [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open].
30+
*/
31+
internal fun dbMetadataFromFileName(fileName: String): DbMetadata {
32+
if (fileName == IN_MEMORY_DB_FILENAME) {
33+
return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY)
34+
}
35+
36+
val trimmed = fileName.trimEnd('/', '\\')
37+
if (trimmed.isEmpty()) {
38+
return DbMetadata(name = null, system = DB_SYSTEM_SQLITE)
39+
}
40+
41+
val index = trimmed.lastIndexOfAny(FILE_NAME_PATH_SEPARATORS)
42+
val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed
43+
return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE)
44+
}
45+
46+
/**
47+
* Resolves metadata from
48+
* [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName].
49+
*/
50+
internal fun dbMetadataFromDatabaseName(databaseName: String?): DbMetadata =
51+
if (databaseName == null) {
52+
DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY)
53+
} else {
54+
DbMetadata(name = databaseName, system = DB_SYSTEM_SQLITE)
55+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package io.sentry.sqlite
2+
3+
import io.sentry.IScopes
4+
import io.sentry.ISpan
5+
import io.sentry.Instrumenter
6+
import io.sentry.SentryDate
7+
import io.sentry.SentryStackTraceFactory
8+
import io.sentry.SpanDataConvention
9+
10+
private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite"
11+
12+
/** Shared span creation and metadata for SQLite instrumentation. */
13+
internal class SQLiteSpanHelper(private val scopes: IScopes, private val dbMetadata: DbMetadata) {
14+
15+
private val stackTraceFactory = SentryStackTraceFactory(scopes.options)
16+
17+
fun startSpan(sql: String, startTimestamp: SentryDate): ISpan? =
18+
scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply {
19+
spanContext.origin = SQLITE_TRACE_ORIGIN
20+
}
21+
22+
fun applyDataToSpan(span: ISpan) {
23+
val isMainThread = scopes.options.threadChecker.isMainThread
24+
span.setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread)
25+
26+
if (isMainThread) {
27+
span.setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack)
28+
}
29+
30+
dbMetadata.name?.let { span.setData(SpanDataConvention.DB_NAME_KEY, it) }
31+
span.setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system)
32+
}
33+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.sentry.sqlite
2+
3+
import io.sentry.IScopes
4+
import io.sentry.ScopesAdapter
5+
import io.sentry.SentryDate
6+
import io.sentry.SentryLevel
7+
import io.sentry.SentryLongDate
8+
import io.sentry.SpanStatus
9+
10+
internal class SQLiteSpanRecorder(
11+
fileName: String,
12+
private val scopes: IScopes = ScopesAdapter.getInstance(),
13+
) {
14+
15+
private val spanHelper = SQLiteSpanHelper(scopes, dbMetadataFromFileName(fileName))
16+
17+
/**
18+
* Returns a start timestamp for a db.sql.query span.
19+
*
20+
* Exposed so callers can capture a wall-clock start before accumulating database time.
21+
* Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace
22+
* timeline, which is less desirable.
23+
*/
24+
fun startTimestamp(): SentryDate = scopes.options.dateProvider.now()
25+
26+
/** Records a db.sql.query span. */
27+
@Suppress("TooGenericExceptionCaught")
28+
fun recordSpan(
29+
sql: String,
30+
startTimestamp: SentryDate,
31+
durationNanos: Long,
32+
status: SpanStatus,
33+
throwable: Throwable? = null,
34+
) {
35+
try {
36+
val span = spanHelper.startSpan(sql, startTimestamp) ?: return
37+
throwable?.let { span.throwable = it }
38+
spanHelper.applyDataToSpan(span)
39+
val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos)
40+
span.finish(status, endTimestamp)
41+
} catch (t: Throwable) {
42+
scopes.options.logger.log(SentryLevel.ERROR, "Failed to record SQLite span.", t)
43+
}
44+
}
45+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 spanRecorder: SQLiteSpanRecorder,
9+
) : SQLiteConnection by delegate {
10+
11+
override fun prepare(sql: String): SQLiteStatement {
12+
val statement = delegate.prepare(sql)
13+
return statement as? SentrySQLiteStatement
14+
?: SentrySQLiteStatement(statement, spanRecorder, sql)
15+
}
16+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
* [io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the same database file. Both wrappers
26+
* instrument at different layers, so combining them will produce duplicate spans for every SQL
27+
* statement.
28+
*
29+
* @param delegate The [SQLiteDriver] instance to delegate calls to.
30+
*/
31+
public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) :
32+
SQLiteDriver {
33+
34+
init {
35+
SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver")
36+
}
37+
38+
@Suppress("TooGenericExceptionCaught")
39+
override fun open(fileName: String): SQLiteConnection {
40+
val connection = delegate.open(fileName)
41+
42+
return try {
43+
val spanRecorder = SQLiteSpanRecorder(fileName)
44+
// create() ensures delegate is unwrapped, so we don't protect against double-wrapping the
45+
// connection.
46+
SentrySQLiteConnection(connection, spanRecorder)
47+
} catch (t: Throwable) {
48+
ScopesAdapter.getInstance()
49+
.options
50+
.logger
51+
.log(
52+
SentryLevel.ERROR,
53+
"Failed to instrument SQLite connection; returning uninstrumented connection.",
54+
t,
55+
)
56+
connection
57+
}
58+
}
59+
60+
public companion object {
61+
62+
@JvmStatic
63+
public fun create(delegate: SQLiteDriver): SQLiteDriver =
64+
delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate)
65+
}
66+
}

0 commit comments

Comments
 (0)