Skip to content

Commit c65e04e

Browse files
feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275)
Introduces support for AndroidX's SQLiteDriver via a new SentrySQLiteDriver wrapper. SentrySQLiteDriver automatically creates Sentry spans for each SQL statement. It's the Driver API / KMP-compatible equivalent of SentrySupportSQLiteOpenHelper. --- Co-authored-by: Angus Holder <7407345+angusholder@users.noreply.github.com>
1 parent 69508a1 commit c65e04e

13 files changed

Lines changed: 1052 additions & 3 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
### Features
66

7+
- Add `SentrySQLiteDriver` for Room 2.7+ `SQLiteDriver` instrumentation in `sentry-android-sqlite`
8+
- Wrap via `SentrySQLiteDriver.create(AndroidSQLiteDriver())` and `Room.databaseBuilder(...).setDriver(...)`
9+
- Span `db.name` is the basename of the path passed to `SQLiteDriver.open()`, which may differ from the Room builder name used by `SentrySupportSQLiteOpenHelper` during migration (e.g. `tracks` vs `tracks.db`)
710
- Add option to attach raw tombstone protobuf on native crash events ([#5446](https://github.com/getsentry/sentry-java/pull/5446))
811
- Enable via `options.isAttachRawTombstone = true` or manifest: `<meta-data android:name="io.sentry.tombstone.attach-raw" android:value="true" />`
912
- Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426))

sentry-android-sqlite/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# sentry-android-sqlite
2+
3+
This module provides automatic SQLite query instrumentation for Android, creating a Sentry span for each SQL statement executed.
4+
5+
Two instrumentation paths are supported, matching the two SQLite APIs offered by AndroidX:
6+
7+
- **`androidx.sqlite.SQLiteDriver`** (Room 2.7+): Wrap your driver with `SentrySQLiteDriver.create(...)` and pass it to `Room.databaseBuilder(...).setDriver(...)`.
8+
- **`androidx.sqlite.db.SupportSQLiteOpenHelper`** (legacy Room): Wrap your open helper with `SentrySupportSQLiteOpenHelper.create(...)`, or let the Sentry Android Gradle plugin apply it automatically.
9+
10+
Use **one** instrumentation path per database file to avoid duplicate spans: either `setDriver` **or** `openHelperFactory`, not both on the same stack.
11+
12+
## Avoiding duplicate spans with Room 2.7+
13+
14+
AndroidX ships a public adapter class, `androidx.sqlite.driver.SupportSQLiteDriver`, which lets developers convert an existing `SupportSQLiteOpenHelper` into a `SQLiteDriver` that Room 2.7+ accepts. **Be careful not to wrap both the open helper and the driver with Sentry!** If you do, you'll produce duplicate spans for every SQL statement. (And remember that the Sentry Android Gradle Plugin will wrap the open helper for you at the byte code level if configured to do so.)
15+
16+
```kotlin
17+
// AVOID — this configuration produces duplicate spans for every SQL statement.
18+
19+
// Step 1: Developer wraps their open helper with Sentry, either manually or
20+
// via the Sentry Android Gradle Plugin.
21+
val sentryWrappedHelper: SupportSQLiteOpenHelper =
22+
SentrySupportSQLiteOpenHelper.create(
23+
FrameworkSQLiteOpenHelperFactory().create(configuration)
24+
)
25+
26+
// Step 2: Developer builds the compat driver around that wrapped helper.
27+
val driver: SQLiteDriver = SupportSQLiteDriver(sentryWrappedHelper)
28+
29+
// Step 3: Developer (wrongly!) wraps the driver with Sentry as well. All
30+
// spans will now be duplicated.
31+
val sentryWrappedDriver: SQLiteDriver = SentrySQLiteDriver.create(driver)
32+
33+
Room.databaseBuilder(context, MyDb::class.java, "mydb")
34+
.setDriver(sentryWrappedDriver)
35+
.build()
36+
```
37+
38+
## Migration
39+
40+
### `db.name` across paths
41+
42+
The two instrumentation paths derive the `db.name` span field differently, which matters while a migration from `openHelperFactory` to `setDriver` is in flight:
43+
44+
- **`SentrySQLiteDriver`** sets `db.name` to the basename of the path passed to `SQLiteDriver.open(fileName)` (e.g., `myapp.db` from `/data/.../databases/myapp.db`). This is *not* the `Room.databaseBuilder` name unless that name happens to match the on-disk filename.
45+
- **`SentrySupportSQLiteOpenHelper`** sets `db.name` from `SupportSQLiteOpenHelper.databaseName`, which for Room is the builder name (e.g., `"tracks"` from `Room.databaseBuilder(context, MyDb::class.java, "tracks")`).
46+
47+
While both paths are in use for the same logical database, expect the same underlying file to appear under two different `db.name` values in the Sentry UI (e.g., `tracks` vs. `tracks.db`).
48+
49+
### Span granularity for multi-statement scripts
50+
51+
The two paths hook in at different layers, which changes how multi-statement scripts are reported:
52+
53+
- **`SentrySupportSQLiteOpenHelper`** wraps high-level calls like `execSQL(String)`. A script such as `"CREATE TABLE ...; INSERT ...; INSERT ...;"` passed to `execSQL` produces a **single** span whose description is the full script.
54+
- **`SentrySQLiteDriver`** wraps `SQLiteStatement.step()`. The Driver API compiles one statement per `prepare(...)` call, so the same logical work is split into separate prepare/step cycles by the caller (or by Room) and produces **one span per statement**.
55+
56+
This is generally a more accurate model — each statement gets its own timing and description — but expect span counts to go up for code paths that previously bundled multiple statements into one `execSQL` call.
57+
58+
## Package layout
59+
60+
This module is organized as two separate packages:
61+
62+
- **`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.
63+
- **`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.
64+
65+
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.
66+
67+
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: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,12 @@ internal class SQLiteSpanManager(
6262
if (isMainThread) {
6363
setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack)
6464
}
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
6765
if (databaseName != null) {
6866
setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite")
6967
setData(SpanDataConvention.DB_NAME_KEY, databaseName)
7068
} else {
7169
setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory")
7270
}
73-
7471
finish()
7572
}
7673
}
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 io.sentry.IScopes
4+
import io.sentry.ISpan
5+
import io.sentry.Instrumenter
6+
import io.sentry.ScopesAdapter
7+
import io.sentry.SentryDate
8+
import io.sentry.SentryIntegrationPackageStorage
9+
import io.sentry.SentryLongDate
10+
import io.sentry.SentryStackTraceFactory
11+
import io.sentry.SpanDataConvention
12+
import io.sentry.SpanStatus
13+
14+
private const val TRACE_ORIGIN = "auto.db.sqlite"
15+
16+
internal class SQLiteSpanRecorder(
17+
private val scopes: IScopes = ScopesAdapter.getInstance(),
18+
private val databaseName: String? = null,
19+
) {
20+
21+
private val stackTraceFactory = SentryStackTraceFactory(scopes.options)
22+
23+
init {
24+
SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite")
25+
}
26+
27+
/**
28+
* Call it to get a start timestamp for a db.sql.query span.
29+
*
30+
* Exposed so callers can capture a wall-clock start before accumulating database time.
31+
* Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace
32+
* timeline, which is less desirable.
33+
*/
34+
fun now(): SentryDate = scopes.options.dateProvider.now()
35+
36+
/** Records a db.sql.query span whose duration equals [durationNanos]. */
37+
fun recordSpan(
38+
sql: String,
39+
startTimestamp: SentryDate,
40+
durationNanos: Long,
41+
status: SpanStatus,
42+
throwable: Throwable? = null,
43+
) {
44+
val span =
45+
scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) ?: return
46+
span.spanContext.origin = TRACE_ORIGIN
47+
if (throwable != null) span.throwable = throwable
48+
applyMetadata(span)
49+
val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos)
50+
span.finish(status, endTimestamp)
51+
}
52+
53+
private fun applyMetadata(span: ISpan) {
54+
val isMainThread = scopes.options.threadChecker.isMainThread
55+
span.setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread)
56+
if (isMainThread) {
57+
span.setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack)
58+
}
59+
if (databaseName != null) {
60+
span.setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite")
61+
span.setData(SpanDataConvention.DB_NAME_KEY, databaseName)
62+
} else {
63+
span.setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory")
64+
}
65+
}
66+
}
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.sentry.sqlite
2+
3+
import androidx.sqlite.SQLiteConnection
4+
import androidx.sqlite.SQLiteDriver
5+
import java.io.File
6+
7+
/**
8+
* Sentinel file name that [SQLiteDriver.open] interprets as a request for an in-memory database.
9+
*/
10+
private const val IN_MEMORY_DB_FILENAME = ":memory:"
11+
12+
/**
13+
* Wraps a [SQLiteDriver] and automatically adds Sentry spans for each SQL statement it executes.
14+
*
15+
* Example usage:
16+
* ```
17+
* val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver())
18+
* ```
19+
*
20+
* If you use Room:
21+
* ```
22+
* val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName")
23+
* .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver()))
24+
* .build()
25+
* ```
26+
*
27+
* @param delegate The [SQLiteDriver] instance to delegate calls to.
28+
*/
29+
public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) :
30+
SQLiteDriver {
31+
32+
override fun open(fileName: String): SQLiteConnection {
33+
val connection = delegate.open(fileName)
34+
val dbName = if (fileName == IN_MEMORY_DB_FILENAME) null else File(fileName).name
35+
val spanRecorder = SQLiteSpanRecorder(databaseName = dbName)
36+
return SentrySQLiteConnection(connection, spanRecorder)
37+
}
38+
39+
public companion object {
40+
41+
@JvmStatic
42+
public fun create(delegate: SQLiteDriver): SQLiteDriver =
43+
delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate)
44+
}
45+
}
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.SQLiteStatement
4+
import io.sentry.SentryDate
5+
import io.sentry.SpanStatus
6+
7+
/**
8+
* Wraps a [SQLiteStatement] and records a single Sentry span covering all [step] calls for the
9+
* statement's lifetime (until the cursor is exhausted, [reset], or [closed][close]).
10+
*
11+
* Span duration is purposefully restricted to accumulated database time, i.e., each [step] call is
12+
* individually timed and the durations are summed. Time the application spends between steps (e.g.,
13+
* processing rows, sleeping, or doing I/O) is intentionally excluded so the span accurately
14+
* represents how long SQLite itself was working.
15+
*
16+
* Not thread-safe: assumes sequential access within each SQL statement (normal SQLite usage).
17+
*/
18+
internal class SentrySQLiteStatement(
19+
private val delegate: SQLiteStatement,
20+
private val spanRecorder: SQLiteSpanRecorder,
21+
private val sql: String,
22+
) : SQLiteStatement by delegate {
23+
24+
private var firstStepTimestamp: SentryDate? = null
25+
private var accumulatedDbNanos: Long = 0L
26+
27+
@Suppress("TooGenericExceptionCaught")
28+
override fun step(): Boolean {
29+
val beforeNanos = System.nanoTime()
30+
31+
if (firstStepTimestamp == null) {
32+
firstStepTimestamp = spanRecorder.now()
33+
}
34+
35+
return try {
36+
val hasMoreRows = delegate.step()
37+
accumulatedDbNanos += System.nanoTime() - beforeNanos
38+
if (!hasMoreRows) {
39+
recordSpan(SpanStatus.OK)
40+
}
41+
hasMoreRows
42+
} catch (e: Throwable) {
43+
accumulatedDbNanos += System.nanoTime() - beforeNanos
44+
recordSpan(SpanStatus.INTERNAL_ERROR, e)
45+
throw e
46+
}
47+
}
48+
49+
override fun reset() {
50+
recordSpan(SpanStatus.OK)
51+
delegate.reset()
52+
}
53+
54+
override fun close() {
55+
recordSpan(SpanStatus.OK)
56+
delegate.close()
57+
}
58+
59+
private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) {
60+
val start = firstStepTimestamp ?: return
61+
val duration = accumulatedDbNanos
62+
firstStepTimestamp = null
63+
accumulatedDbNanos = 0L
64+
spanRecorder.recordSpan(sql, start, duration, status, throwable)
65+
}
66+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.sentry.sqlite
2+
3+
import androidx.sqlite.SQLiteStatement
4+
import io.sentry.android.sqlite.SQLiteSpanManager
5+
6+
/**
7+
* Test-only [SQLiteStatement] used in characterization tests for duplicate database spans.
8+
*
9+
* ### What production scenario this models
10+
*
11+
* Room can use `setDriver(SentrySQLiteDriver.create(...))` while the delegate driver still sits on
12+
* top of the **legacy** SQLite stack. A common migration setup is:
13+
* ```
14+
* SentrySQLiteDriver → SupportSQLiteDriver → SentrySupportSQLiteOpenHelper (SAGP or manual wrap)
15+
* ```
16+
*
17+
* Room only calls the driver API (`prepare` / `step`), but the delegate translates `step()` into
18+
* legacy support calls that are **already** wrapped by [SentrySupportSQLiteStatement] (spans on
19+
* `execute()`, etc.). [SentrySQLiteDriver] then wraps `step()` again → two `db.sql.query` spans for
20+
* one query.
21+
*
22+
* ### Why we need a test double instead of [SentrySupportSQLiteStatement]
23+
*
24+
* [SentrySupportSQLiteStatement] implements [androidx.sqlite.db.SupportSQLiteStatement]. Driver
25+
* `prepare()` must return [SQLiteStatement]. We cannot return the real legacy wrapper from a unit
26+
* test of [SentrySQLiteConnection.prepare], so this class reproduces the important part: the
27+
* delegate's [step] already runs [SQLiteSpanManager.performSql] before [SentrySQLiteDriver]'s
28+
* wrapper runs it again.
29+
*
30+
* ### How tests use this class
31+
*
32+
* Characterization tests assert the **current** SDK behavior (two spans). The recommended app setup
33+
* is `SentrySQLiteDriver.create(AndroidSQLiteDriver())` with no instrumented support stack below it
34+
* — see [SentrySQLiteDriver] KDoc.
35+
*/
36+
internal class LegacyInstrumentedSQLiteStatement(
37+
private val delegate: SQLiteStatement,
38+
private val spanManager: SQLiteSpanManager,
39+
private val sql: String,
40+
) : SQLiteStatement by delegate {
41+
42+
override fun step(): Boolean = spanManager.performSql(sql) { delegate.step() }
43+
}

0 commit comments

Comments
 (0)