Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Features

- Add `SentrySQLiteDriver` for Room 2.7+ `SQLiteDriver` instrumentation in `sentry-android-sqlite` ([#5466](https://github.com/getsentry/sentry-java/pull/5466))
- Wrap via `SentrySQLiteDriver.create(AndroidSQLiteDriver())` and `Room.databaseBuilder(...).setDriver(...)`
- 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`)
- Add option to attach raw tombstone protobuf on native crash events ([#5446](https://github.com/getsentry/sentry-java/pull/5446))
- Enable via `options.isAttachRawTombstone = true` or manifest: `<meta-data android:name="io.sentry.tombstone.attach-raw" android:value="true" />`
- Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426))
Expand Down
67 changes: 67 additions & 0 deletions sentry-android-sqlite/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# sentry-android-sqlite

This module provides automatic SQLite query instrumentation for Android, creating a Sentry span for each SQL statement executed.

Two instrumentation paths are supported, matching the two SQLite APIs offered by AndroidX:

- **`androidx.sqlite.SQLiteDriver`** (Room 2.7+): Wrap your driver with `SentrySQLiteDriver.create(...)` and pass it to `Room.databaseBuilder(...).setDriver(...)`.
- **`androidx.sqlite.db.SupportSQLiteOpenHelper`** (legacy Room): Wrap your open helper with `SentrySupportSQLiteOpenHelper.create(...)`, or let the Sentry Android Gradle plugin apply it automatically.

Use **one** instrumentation path per database file to avoid duplicate spans: either `setDriver` **or** `openHelperFactory`, not both on the same stack.

## Avoiding duplicate spans with Room 2.7+

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

```kotlin
// AVOID โ€” this configuration produces duplicate spans for every SQL statement.

// Step 1: Developer wraps their open helper with Sentry, either manually or
// via the Sentry Android Gradle Plugin.
val sentryWrappedHelper: SupportSQLiteOpenHelper =
SentrySupportSQLiteOpenHelper.create(
FrameworkSQLiteOpenHelperFactory().create(configuration)
)

// Step 2: Developer builds the compat driver around that wrapped helper.
val driver: SQLiteDriver = SupportSQLiteDriver(sentryWrappedHelper)

// Step 3: Developer (wrongly!) wraps the driver with Sentry as well. All
// spans will now be duplicated.
val sentryWrappedDriver: SQLiteDriver = SentrySQLiteDriver.create(driver)

Room.databaseBuilder(context, MyDb::class.java, "mydb")
.setDriver(sentryWrappedDriver)
.build()
```

## Migration

### `db.name` across paths

The two instrumentation paths derive the `db.name` span field differently, which matters while a migration from `openHelperFactory` to `setDriver` is in flight:

- **`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.
- **`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")`).

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`).

### Span granularity for multi-statement scripts

The two paths hook in at different layers, which changes how multi-statement scripts are reported:

- **`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.
- **`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**.

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.

## Package layout

This module is organized as two separate packages:

- **`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.
- **`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.

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.

Note that the module artifact itself (`sentry-android-sqlite`) is currently an Android-only AAR regardless of package layout.
11 changes: 11 additions & 0 deletions sentry-android-sqlite/api/sentry-android-sqlite.api
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,14 @@ public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Compan
public final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper;
}

public final class io/sentry/sqlite/SentrySQLiteDriver : androidx/sqlite/SQLiteDriver {
public static final field Companion Lio/sentry/sqlite/SentrySQLiteDriver$Companion;
public synthetic fun <init> (Landroidx/sqlite/SQLiteDriver;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public static final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver;
public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection;
}

public final class io/sentry/sqlite/SentrySQLiteDriver$Companion {
public final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver;
}

Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,12 @@ internal class SQLiteSpanManager(
if (isMainThread) {
setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack)
}
// if db name is null, then it's an in-memory database as per
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.kt;l=38-42
if (databaseName != null) {
setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite")
setData(SpanDataConvention.DB_NAME_KEY, databaseName)
} else {
setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory")
}

finish()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.sentry.sqlite

import io.sentry.IScopes
import io.sentry.ISpan
import io.sentry.Instrumenter
import io.sentry.ScopesAdapter
import io.sentry.SentryDate
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLongDate
import io.sentry.SentryStackTraceFactory
import io.sentry.SpanDataConvention
import io.sentry.SpanStatus

private const val TRACE_ORIGIN = "auto.db.sqlite"

internal class SQLiteSpanRecorder(
private val scopes: IScopes = ScopesAdapter.getInstance(),
private val databaseName: String? = null,
) {

private val stackTraceFactory = SentryStackTraceFactory(scopes.options)

init {
SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite")
}

/**
* Call it to get a start timestamp for a db.sql.query span.
*
* Exposed so callers can capture a wall-clock start before accumulating database time.
* Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace
* timeline, which is less desirable.
*/
fun now(): SentryDate = scopes.options.dateProvider.now()

/** Records a db.sql.query span whose duration equals [durationNanos]. */
fun recordSpan(
sql: String,
startTimestamp: SentryDate,
durationNanos: Long,
status: SpanStatus,
throwable: Throwable? = null,
) {
val span =
scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) ?: return
span.spanContext.origin = TRACE_ORIGIN
if (throwable != null) span.throwable = throwable
applyMetadata(span)
val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos)
span.finish(status, endTimestamp)
}

private fun applyMetadata(span: ISpan) {
val isMainThread = scopes.options.threadChecker.isMainThread
span.setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread)
if (isMainThread) {
span.setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack)
}
if (databaseName != null) {
span.setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite")
span.setData(SpanDataConvention.DB_NAME_KEY, databaseName)
} else {
span.setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.sentry.sqlite

import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteStatement

internal class SentrySQLiteConnection(
private val delegate: SQLiteConnection,
private val spanRecorder: SQLiteSpanRecorder,
) : SQLiteConnection by delegate {

override fun prepare(sql: String): SQLiteStatement {
val statement = delegate.prepare(sql)
return statement as? SentrySQLiteStatement
?: SentrySQLiteStatement(statement, spanRecorder, sql)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.sentry.sqlite

import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
import java.io.File

/**
* Sentinel file name that [SQLiteDriver.open] interprets as a request for an in-memory database.
*/
private const val IN_MEMORY_DB_FILENAME = ":memory:"

/**
* Wraps a [SQLiteDriver] and automatically adds Sentry spans for each SQL statement it executes.
*
* Example usage:
* ```
* val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver())
* ```
*
* If you use Room:
* ```
* val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName")
* .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver()))
* .build()
* ```
*
* @param delegate The [SQLiteDriver] instance to delegate calls to.
*/
public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) :
SQLiteDriver {

override fun open(fileName: String): SQLiteConnection {
val connection = delegate.open(fileName)
val dbName = if (fileName == IN_MEMORY_DB_FILENAME) null else File(fileName).name
val spanRecorder = SQLiteSpanRecorder(databaseName = dbName)
return SentrySQLiteConnection(connection, spanRecorder)
}

public companion object {

@JvmStatic
public fun create(delegate: SQLiteDriver): SQLiteDriver =
delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.sentry.sqlite

import androidx.sqlite.SQLiteStatement
import io.sentry.SentryDate
import io.sentry.SpanStatus

/**
* Wraps a [SQLiteStatement] and records a single Sentry span covering all [step] calls for the
* statement's lifetime (until the cursor is exhausted, [reset], or [closed][close]).
*
* Span duration is purposefully restricted to accumulated database time, i.e., each [step] call is
* individually timed and the durations are summed. Time the application spends between steps (e.g.,
* processing rows, sleeping, or doing I/O) is intentionally excluded so the span accurately
* represents how long SQLite itself was working.
*
* Not thread-safe: assumes sequential access within each SQL statement (normal SQLite usage).
*/
internal class SentrySQLiteStatement(
private val delegate: SQLiteStatement,
private val spanRecorder: SQLiteSpanRecorder,
private val sql: String,
) : SQLiteStatement by delegate {

private var firstStepTimestamp: SentryDate? = null
private var accumulatedDbNanos: Long = 0L

@Suppress("TooGenericExceptionCaught")
override fun step(): Boolean {
val beforeNanos = System.nanoTime()

if (firstStepTimestamp == null) {
firstStepTimestamp = spanRecorder.now()
}

return try {
val hasMoreRows = delegate.step()
accumulatedDbNanos += System.nanoTime() - beforeNanos
if (!hasMoreRows) {
recordSpan(SpanStatus.OK)
}
hasMoreRows
} catch (e: Throwable) {
accumulatedDbNanos += System.nanoTime() - beforeNanos
recordSpan(SpanStatus.INTERNAL_ERROR, e)
throw e
}
}

override fun reset() {
recordSpan(SpanStatus.OK)
delegate.reset()
}

override fun close() {
recordSpan(SpanStatus.OK)
delegate.close()
}

private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) {
val start = firstStepTimestamp ?: return
val duration = accumulatedDbNanos
firstStepTimestamp = null
accumulatedDbNanos = 0L
spanRecorder.recordSpan(sql, start, duration, status, throwable)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.sentry.sqlite

import androidx.sqlite.SQLiteStatement
import io.sentry.android.sqlite.SQLiteSpanManager

/**
* Test-only [SQLiteStatement] used in characterization tests for duplicate database spans.
*
* ### What production scenario this models
*
* Room can use `setDriver(SentrySQLiteDriver.create(...))` while the delegate driver still sits on
* top of the **legacy** SQLite stack. A common migration setup is:
* ```
* SentrySQLiteDriver โ†’ SupportSQLiteDriver โ†’ SentrySupportSQLiteOpenHelper (SAGP or manual wrap)
* ```
*
* Room only calls the driver API (`prepare` / `step`), but the delegate translates `step()` into
* legacy support calls that are **already** wrapped by [SentrySupportSQLiteStatement] (spans on
* `execute()`, etc.). [SentrySQLiteDriver] then wraps `step()` again โ†’ two `db.sql.query` spans for
* one query.
*
* ### Why we need a test double instead of [SentrySupportSQLiteStatement]
*
* [SentrySupportSQLiteStatement] implements [androidx.sqlite.db.SupportSQLiteStatement]. Driver
* `prepare()` must return [SQLiteStatement]. We cannot return the real legacy wrapper from a unit
* test of [SentrySQLiteConnection.prepare], so this class reproduces the important part: the
* delegate's [step] already runs [SQLiteSpanManager.performSql] before [SentrySQLiteDriver]'s
* wrapper runs it again.
*
* ### How tests use this class
*
* Characterization tests assert the **current** SDK behavior (two spans). The recommended app setup
* is `SentrySQLiteDriver.create(AndroidSQLiteDriver())` with no instrumented support stack below it
* โ€” see [SentrySQLiteDriver] KDoc.
*/
internal class LegacyInstrumentedSQLiteStatement(
private val delegate: SQLiteStatement,
private val spanManager: SQLiteSpanManager,
private val sql: String,
) : SQLiteStatement by delegate {

override fun step(): Boolean = spanManager.performSql(sql) { delegate.step() }
}
Loading
Loading