Skip to content

Commit 8c92b97

Browse files
authored
Merge f2207a5 into b936425
2 parents b936425 + f2207a5 commit 8c92b97

16 files changed

Lines changed: 1156 additions & 36 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
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+
- 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.
11+
- See https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/ for more details, including info about migrating from `SentrySupportSQLiteOpenHelper`
12+
313
## 8.43.1
414

515
### Fixes

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo
9494
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
9595
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" }
9696
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
97-
androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" }
97+
androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" }
9898
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" }
9999
androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" }
100100
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+
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 `SQLiteSpanManager` wrapper live here.
17+
- **`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.
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,16 @@ 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 getHasConnectionPool ()Z
29+
public synthetic fun hasConnectionPool ()Z
30+
public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection;
31+
}
32+
33+
public final class io/sentry/sqlite/SentrySQLiteDriver$Companion {
34+
public final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver;
35+
}
36+

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: 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 { it in FILE_NAME_PATH_SEPARATORS }
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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 creation and metadata for SQLite instrumentation. */
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+
fun fromDatabaseName(databaseName: String?, scopes: IScopes = ScopesAdapter.getInstance()) =
80+
SQLiteSpanInstrumentation(scopes, dbMetadataFromDatabaseName(databaseName))
81+
82+
fun fromFileName(fileName: String, scopes: IScopes = ScopesAdapter.getInstance()) =
83+
SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName))
84+
}
85+
}
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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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, so combining them will produce
27+
* duplicate spans for every SQL 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+
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+
public companion object {
70+
71+
@JvmStatic
72+
public fun create(delegate: SQLiteDriver): SQLiteDriver =
73+
delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate)
74+
}
75+
}

0 commit comments

Comments
 (0)