Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Features

- Add `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting AndroidX's `SQLiteDriver` ([#5466](https://github.com/getsentry/sentry-java/pull/5466))
- Automatically generates spans for all SQLite statements
- To use it, pass your `SQLiteDriver` to `SentrySQLiteDriver.create(...)`
- You'll need `androidx.sqlite:sqlite` (2.5.0+) on your app's classpath (Room usually provides it for you). androidx.sqlite 2.6.0+ requires minSdk 23.
- See https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/ for more details, including info about migrating from `SentrySupportSQLiteOpenHelper`

## 8.43.1

### Fixes
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" }
androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" }
androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" }
async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" }
Expand Down
21 changes: 21 additions & 0 deletions sentry-android-sqlite/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# sentry-android-sqlite

This module provides automatic SQLite query instrumentation for Android.

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

- **`androidx.sqlite.SQLiteDriver`** — used by Room 2.7+ via `Room.databaseBuilder(...).setDriver(...)` and by SQLDelight via its AndroidX SQLite driver.
- **`androidx.sqlite.db.SupportSQLiteOpenHelper`** — used by legacy Room via `Room.databaseBuilder(...).openHelperFactory(...)`, or applied automatically by the Sentry Android Gradle plugin.

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.

## 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 `SQLiteSpanManager` wrapper live here.
- **`io.sentry.sqlite`**: Code whose contract depends only on the multiplatform `androidx.sqlite.*` interfaces (e.g., `SQLiteDriver` and `SQLiteConnection`). `SentrySQLiteDriver` and shared span instrumentation via `SQLiteSpanInstrumentation` 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.
13 changes: 13 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,16 @@ 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 getHasConnectionPool ()Z
public synthetic fun hasConnectionPool ()Z
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 @@ -3,21 +3,17 @@ package io.sentry.android.sqlite
import android.database.CrossProcessCursor
import android.database.SQLException
import io.sentry.IScopes
import io.sentry.ISpan
import io.sentry.Instrumenter
import io.sentry.ScopesAdapter
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryStackTraceFactory
import io.sentry.SpanDataConvention
import io.sentry.SpanStatus

private const val TRACE_ORIGIN = "auto.db.sqlite"
import io.sentry.sqlite.SQLiteSpanInstrumentation

internal class SQLiteSpanManager(
private val scopes: IScopes = ScopesAdapter.getInstance(),
private val databaseName: String? = null,
databaseName: String? = null,
) {
private val stackTraceFactory = SentryStackTraceFactory(scopes.options)

private val spans = SQLiteSpanInstrumentation.fromDatabaseName(databaseName, scopes)

init {
SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite")
Expand All @@ -33,8 +29,8 @@ internal class SQLiteSpanManager(
@Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST")
@Throws(SQLException::class)
fun <T> performSql(sql: String, operation: () -> T): T {
val startTimestamp = scopes.getOptions().dateProvider.now()
var span: ISpan? = null
val startTimestamp = spans.startTimestamp()

return try {
val result = operation()
/*
Expand All @@ -45,34 +41,11 @@ internal class SQLiteSpanManager(
if (result is CrossProcessCursor) {
return SentryCrossProcessCursor(result, this, sql) as T
}
span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)
span?.spanContext?.origin = TRACE_ORIGIN
span?.status = SpanStatus.OK
spans.recordSpan(sql, startTimestamp, SpanStatus.OK)
result
} catch (e: Throwable) {
span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)
span?.spanContext?.origin = TRACE_ORIGIN
span?.status = SpanStatus.INTERNAL_ERROR
span?.throwable = e
spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e)
throw e
} finally {
span?.apply {
val isMainThread: Boolean = scopes.options.threadChecker.isMainThread
setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread)
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()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package io.sentry.sqlite

/**
* Value associated with [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] for in-memory
* databases.
*/
internal const val DB_SYSTEM_IN_MEMORY = "in-memory"

/**
* Value associated with [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] for SQLite
* databases.
*/
internal const val DB_SYSTEM_SQLITE = "sqlite"

/**
* Sentinel file name that [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open] interprets as an
* in-memory database:
* https://developer.android.com/reference/androidx/sqlite/driver/AndroidSQLiteDriver.
*/
private const val IN_MEMORY_DB_FILENAME = ":memory:"

/** Path separators matching [File.separatorChar][java.io.File.separatorChar]. */
private val FILE_NAME_PATH_SEPARATORS = charArrayOf('/', '\\')

internal data class DbMetadata(val name: String?, val system: String)

/**
* Resolves metadata from the [fileName] argument to
* [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open].
*/
internal fun dbMetadataFromFileName(fileName: String): DbMetadata {
if (fileName == IN_MEMORY_DB_FILENAME) {
return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY)
}

val trimmed = fileName.trimEnd { it in FILE_NAME_PATH_SEPARATORS }
if (trimmed.isEmpty()) {
return DbMetadata(name = null, system = DB_SYSTEM_SQLITE)
}

val index = trimmed.lastIndexOfAny(FILE_NAME_PATH_SEPARATORS)
val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed
return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE)
}

/**
* Resolves metadata from
* [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName].
*/
internal fun dbMetadataFromDatabaseName(databaseName: String?): DbMetadata =
if (databaseName == null) {
DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY)
} else {
DbMetadata(name = databaseName, system = DB_SYSTEM_SQLITE)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package io.sentry.sqlite

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

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

/** Shared span creation and metadata for SQLite instrumentation. */
internal class SQLiteSpanInstrumentation(
private val scopes: IScopes,
private val dbMetadata: DbMetadata,
) {

private val stackTraceFactory = SentryStackTraceFactory(scopes.options)

/**
* Returns 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 startTimestamp(): SentryDate = scopes.options.dateProvider.now()

/** Records a `db.sql.query` span from [startTimestamp] to the moment of invocation. */
fun recordSpan(
sql: String,
startTimestamp: SentryDate,
status: SpanStatus,
throwable: Throwable? = null,
) {
recordSpan(sql, startTimestamp, endTimestamp = null, status, throwable)
}

/** Records a `db.sql.query` span from [startTimestamp] to [startTimestamp] + [durationNanos]. */
fun recordSpan(
sql: String,
startTimestamp: SentryDate,
durationNanos: Long,
status: SpanStatus,
throwable: Throwable? = null,
) {
val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos)
recordSpan(sql, startTimestamp, endTimestamp, status, throwable)
}

private fun recordSpan(
sql: String,
startTimestamp: SentryDate,
endTimestamp: SentryDate?,
status: SpanStatus,
throwable: Throwable?,
) {
scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply {
spanContext.origin = SQLITE_TRACE_ORIGIN
throwable?.let { this.throwable = it }

val isMainThread = scopes.options.threadChecker.isMainThread
setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread)

if (isMainThread) {
setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack)
}

dbMetadata.name?.let { setData(SpanDataConvention.DB_NAME_KEY, it) }
setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system)
finish(status, endTimestamp)
}
}

companion object {

fun fromDatabaseName(databaseName: String?, scopes: IScopes = ScopesAdapter.getInstance()) =
SQLiteSpanInstrumentation(scopes, dbMetadataFromDatabaseName(databaseName))

fun fromFileName(fileName: String, scopes: IScopes = ScopesAdapter.getInstance()) =
SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.sentry.sqlite

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

internal class SentrySQLiteConnection(
private val delegate: SQLiteConnection,
private val spans: SQLiteSpanInstrumentation,
) : SQLiteConnection by delegate {

override fun prepare(sql: String): SQLiteStatement {
val statement = delegate.prepare(sql)
return statement as? SentrySQLiteStatement ?: SentrySQLiteStatement(statement, spans, sql)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package io.sentry.sqlite
Comment thread
0xadam-brown marked this conversation as resolved.

import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
import io.sentry.ScopesAdapter
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel

/**
* Wraps a [SQLiteDriver] and automatically adds 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()
* ```
*
* **Warning:** Do not use [SentrySQLiteDriver] together with
* [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the
* same database file. Both wrappers instrument at different layers, so combining them will produce
* duplicate spans for every SQL statement.
*
* @param delegate The [SQLiteDriver] instance to delegate calls to.
*/
public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) :
Comment thread
0xadam-brown marked this conversation as resolved.
Outdated
SQLiteDriver {

init {
SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver")
Comment thread
0xadam-brown marked this conversation as resolved.
}

override val hasConnectionPool: Boolean
get() =
try {
delegate.hasConnectionPool
} catch (_: LinkageError) {
Comment thread
0xadam-brown marked this conversation as resolved.
// Delegates on androidx.sqlite < 2.6.0 won't have a hasConnectionPool property.
false
}

@Suppress("TooGenericExceptionCaught")
override fun open(fileName: String): SQLiteConnection {
val connection = delegate.open(fileName)

return try {
val spans = SQLiteSpanInstrumentation.fromFileName(fileName)
// create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping
// the connection.
SentrySQLiteConnection(connection, spans)
} catch (t: Throwable) {
ScopesAdapter.getInstance()
.options
.logger
.log(
SentryLevel.ERROR,
"Failed to instrument SQLite connection; returning uninstrumented connection.",
t,
)
connection
Comment thread
0xadam-brown marked this conversation as resolved.
}
}

public companion object {

@JvmStatic
public fun create(delegate: SQLiteDriver): SQLiteDriver =
delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate)
}
}
Loading
Loading