From c65e04e80a52cd8a9a2d609b659ec13daf9342e2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 20 May 2026 14:59:54 +0200 Subject: [PATCH 1/2] 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> --- CHANGELOG.md | 3 + sentry-android-sqlite/README.md | 67 +++++ .../api/sentry-android-sqlite.api | 11 + .../android/sqlite/SQLiteSpanManager.kt | 3 - .../io/sentry/sqlite/SQLiteSpanRecorder.kt | 66 +++++ .../sentry/sqlite/SentrySQLiteConnection.kt | 16 ++ .../io/sentry/sqlite/SentrySQLiteDriver.kt | 45 +++ .../io/sentry/sqlite/SentrySQLiteStatement.kt | 66 +++++ .../LegacyInstrumentedSQLiteStatement.kt | 43 +++ .../sentry/sqlite/SQLiteSpanRecorderTest.kt | 160 +++++++++++ .../sqlite/SentrySQLiteConnectionTest.kt | 152 ++++++++++ .../sentry/sqlite/SentrySQLiteDriverTest.kt | 153 ++++++++++ .../sqlite/SentrySQLiteStatementTest.kt | 270 ++++++++++++++++++ 13 files changed, 1052 insertions(+), 3 deletions(-) create mode 100644 sentry-android-sqlite/README.md create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/LegacyInstrumentedSQLiteStatement.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index fca85f96435..e2a98c0865f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features +- Add `SentrySQLiteDriver` for Room 2.7+ `SQLiteDriver` instrumentation in `sentry-android-sqlite` + - 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: `` - Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426)) diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md new file mode 100644 index 00000000000..089ebad0b1a --- /dev/null +++ b/sentry-android-sqlite/README.md @@ -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. diff --git a/sentry-android-sqlite/api/sentry-android-sqlite.api b/sentry-android-sqlite/api/sentry-android-sqlite.api index c8780f1338d..6a62613dfc2 100644 --- a/sentry-android-sqlite/api/sentry-android-sqlite.api +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -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 (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; +} + diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt index 1bdeb7d369c..3fa6dd83391 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -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() } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt new file mode 100644 index 00000000000..19904d59713 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt @@ -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") + } + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt new file mode 100644 index 00000000000..b83c74dae1b --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -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) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt new file mode 100644 index 00000000000..b5232d47bc5 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -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) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt new file mode 100644 index 00000000000..dc2d22e2512 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -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) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/LegacyInstrumentedSQLiteStatement.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/LegacyInstrumentedSQLiteStatement.kt new file mode 100644 index 00000000000..6c5bd3985eb --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/LegacyInstrumentedSQLiteStatement.kt @@ -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() } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt new file mode 100644 index 00000000000..65ef9ff4732 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanRecorderTest.kt @@ -0,0 +1,160 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.util.thread.IThreadChecker +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Before +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SQLiteSpanRecorderTest { + + private class Fixture { + val scopes = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): SQLiteSpanRecorder { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + if (isSpanActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + return SQLiteSpanRecorder(scopes, databaseName) + } + } + + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun `registers SQLite integration on construction`() { + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLite")) + fixture.getSut() + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLite")) + } + + @Test + fun `recordSpan creates a span with correct operation and description`() { + val sut = fixture.getSut() + val start = sut.now() + sut.recordSpan("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT * FROM users", span.description) + assertEquals("auto.db.sqlite", span.spanContext.origin) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `recordSpan sets span duration to durationNanos`() { + val sut = fixture.getSut() + val start = sut.now() + val durationNanos = 42_000_000L + + sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + val actualDuration = span.finishDate!!.nanoTimestamp() - span.startDate.nanoTimestamp() + assertEquals(durationNanos, actualDuration) + } + + @Test + fun `recordSpan is a no-op when no active span`() { + val sut = fixture.getSut(isSpanActive = false) + val start = sut.now() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `recordSpan attaches throwable on error status`() { + val sut = fixture.getSut() + val start = sut.now() + val exception = RuntimeException("disk I/O error") + + sut.recordSpan("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) + + val span = fixture.sentryTracer.children.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + @Test + fun `recordSpan sets databaseName as db system and name`() { + val sut = fixture.getSut(databaseName = "tracks.db") + val start = sut.now() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `recordSpan sets in-memory when databaseName is null`() { + val sut = fixture.getSut(databaseName = null) + val start = sut.now() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertNull(span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `recordSpan sets blocked_main_thread to false on background thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") + + sut.recordSpan("SELECT 1", sut.now(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `recordSpan sets blocked_main_thread to true and attaches call stack on main thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") + + sut.recordSpan("SELECT 1", sut.now(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNotNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `now returns a SentryDate from the date provider`() { + val sut = fixture.getSut() + val date = sut.now() + assertNotNull(date) + assertTrue(date.nanoTimestamp() > 0) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt new file mode 100644 index 00000000000..262303e7239 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -0,0 +1,152 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.android.sqlite.SQLiteSpanManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteConnectionTest { + + private class Fixture { + val scopes = mock() + val mockConnection = mock() + val mockStatement = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut(isSpanActive: Boolean = true): SentrySQLiteConnection { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + if (isSpanActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + whenever(mockConnection.prepare("SELECT 1")).thenReturn(mockStatement) + val spanRecorder = SQLiteSpanRecorder(scopes) + return SentrySQLiteConnection(mockConnection, spanRecorder) + } + } + + private val fixture = Fixture() + + @Test + fun `prepare returns a SentrySQLiteStatement`() { + val sut = fixture.getSut() + val statement = sut.prepare("SELECT 1") + assertIs(statement) + } + + @Test + fun `step on prepared statement creates a span`() { + val sut = fixture.getSut() + val statement = sut.prepare("SELECT 1") + assertEquals(0, fixture.sentryTracer.children.size) + statement.step() + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT 1", span.description) + assertTrue(span.isFinished) + } + + @Test + fun `prepare returns existing SentrySQLiteStatement without re-wrapping`() { + val sut = fixture.getSut() + val spanRecorder = SQLiteSpanRecorder(fixture.scopes) + val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spanRecorder, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) + + val statement = sut.prepare("SELECT 1") + + assertSame(alreadyInstrumented, statement) + } + + @Test + fun `prepare does not double-wrap an already instrumented statement on step`() { + val sut = fixture.getSut() + val spanRecorder = SQLiteSpanRecorder(fixture.scopes) + val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spanRecorder, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) + sut.prepare("SELECT 1").step() + + assertEquals(1, fixture.sentryTracer.children.size) + } + + /* + * Characterization test (not a "one span" regression guard). + * + * SentrySQLiteConnection.prepare() skips re-wrap only when the delegate already returned a + * SentrySQLiteStatement (see tests above). Other instrumented statement types are still wrapped. + * + * Here the delegate returns LegacyInstrumentedSQLiteStatement, which models a driver bridge + * (e.g. SupportSQLiteDriver) over an already-instrumented legacy open helper. We expect + * prepare() to add another SentrySQLiteStatement layer anyway — that is intentional today and + * is why the follow-up test expects two db.sql.query spans on a single step(). + */ + @Test + fun `prepare wraps delegate statement with legacy-style step instrumentation`() { + val sut = fixture.getSut() + val spanManager = SQLiteSpanManager(fixture.scopes) + val legacyInstrumented = + LegacyInstrumentedSQLiteStatement(fixture.mockStatement, spanManager, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(legacyInstrumented) + + val statement = sut.prepare("SELECT 1") + + assertIs(statement) + assertTrue(statement !== legacyInstrumented) + } + + /* + * Characterization test: documents duplicate db.sql.query spans for the risky migration stack. + * + * Call chain under test: + * SentrySQLiteConnection.prepare() → SentrySQLiteStatement.step() + * → LegacyInstrumentedSQLiteStatement.step() (span #1, legacy path) + * → mock delegate.step() + * + * This is the scenario Roman flagged: SentrySQLiteDriver on top of a delegate whose statements + * are already instrumented (SupportSQLiteDriver + SAGP-wrapped open helper). The SDK does not + * detect that case in prepare() — only SentrySQLiteStatement instances are passed through. + * + * Expected behavior today: 2 child spans. Do not change this test to expect 1 unless we add + * explicit detection for legacy-instrumented delegates. The fix for app developers is + * configuration: use SentrySQLiteDriver.create(AndroidSQLiteDriver()), one instrumentation path + * per database, and avoid stacking driver wrap on SupportSQLiteDriver over an instrumented helper. + */ + @Test + fun `wrapping legacy-style instrumented delegate statement produces two spans on step`() { + val sut = fixture.getSut() + val spanManager = SQLiteSpanManager(fixture.scopes) + val legacyInstrumented = + LegacyInstrumentedSQLiteStatement(fixture.mockStatement, spanManager, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(legacyInstrumented) + sut.prepare("SELECT 1").step() + + // Span 1: LegacyInstrumentedSQLiteStatement.step(). Span 2: SentrySQLiteStatement.step(). + assertEquals(2, fixture.sentryTracer.children.size) + fixture.sentryTracer.children.forEach { span -> + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT 1", span.description) + } + } + + @Test + fun `close delegates to underlying connection`() { + val sut = fixture.getSut() + sut.close() + verify(fixture.mockConnection).close() + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt new file mode 100644 index 00000000000..9edee281544 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -0,0 +1,153 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionOptions +import io.sentry.android.sqlite.SQLiteSpanManager +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SentrySQLiteDriverTest { + + private class Fixture { + val mockDriver = mock() + val mockConnection = mock() + val mockStatement = mock() + + fun setupDriver(fileName: String): SentrySQLiteDriver { + whenever(mockDriver.open(fileName)).thenReturn(mockConnection) + whenever(mockConnection.prepare(org.mockito.kotlin.any())).thenReturn(mockStatement) + return SentrySQLiteDriver.create(mockDriver) as SentrySQLiteDriver + } + } + + private val fixture = Fixture() + + @BeforeTest + fun setup() { + Sentry.init { options: SentryOptions -> + options.dsn = "https://key@sentry.io/proj" + options.tracesSampleRate = 1.0 + } + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + @Test + fun `create with non-wrapped driver returns SentrySQLiteDriver`() { + val result = SentrySQLiteDriver.create(fixture.mockDriver) + assertIs(result) + } + + @Test + fun `create with already-wrapped driver returns same instance`() { + val wrapped = SentrySQLiteDriver.create(fixture.mockDriver) + val doubleWrapped = SentrySQLiteDriver.create(wrapped) + assertSame(wrapped, doubleWrapped) + } + + @Test + fun `open with named DB file returns SentrySQLiteConnection`() { + val driver = fixture.setupDriver("myapp.db") + val connection = driver.open("myapp.db") + assertIs(connection) + } + + @Test + fun `open with named DB file - step creates span with DB_SYSTEM_KEY sqlite and DB_NAME_KEY filename`() { + val driver = fixture.setupDriver("myapp.db") + val connection = driver.open("myapp.db") + + val txOptions = TransactionOptions().apply { isBindToScope = true } + val tx = Sentry.startTransaction("name", "op", txOptions) as SentryTracer + connection.prepare("SELECT 1").step() + tx.finish() + + val span = tx.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("myapp.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `open with memory DB - step creates span with DB_SYSTEM_KEY in-memory and no DB_NAME_KEY`() { + val driver = fixture.setupDriver(":memory:") + val connection = driver.open(":memory:") + + val txOptions = TransactionOptions().apply { isBindToScope = true } + val tx = Sentry.startTransaction("name", "op", txOptions) as SentryTracer + connection.prepare("SELECT 1").step() + tx.finish() + + val span = tx.children.firstOrNull() + assertNotNull(span) + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertNull(span.data[SpanDataConvention.DB_NAME_KEY]) + } + + /* + * Characterization test: same duplicate-span scenario as SentrySQLiteConnectionTest, exercised + * through the public API developers use (SentrySQLiteDriver.open → connection.prepare → step). + * + * Uses LegacyInstrumentedSQLiteStatement as the object returned from the mock driver's + * connection.prepare(), standing in for a SupportSQLite bridge over SAGP-instrumented support + * SQLite. Expect 2 db.sql.query spans — not a failure, but a record of current behavior when + * instrumentation is stacked. Safe production setup: uninstrumented AndroidSQLiteDriver delegate. + */ + @Test + fun `open with legacy-instrumented delegate statement produces two spans on step`() { + val driver = fixture.setupDriver("app.db") + val legacyStatement = + LegacyInstrumentedSQLiteStatement(fixture.mockStatement, SQLiteSpanManager(), "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(legacyStatement) + val txOptions = TransactionOptions().apply { isBindToScope = true } + val tx = Sentry.startTransaction("name", "op", txOptions) as SentryTracer + driver.open("app.db").prepare("SELECT 1").step() + tx.finish() + + // Span 1: LegacyInstrumentedSQLiteStatement.step(). Span 2: SentrySQLiteStatement.step(). + assertEquals(2, tx.children.size) + tx.children.forEach { span -> + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT 1", span.description) + } + } + + @Test + fun `open with full path - DB_NAME_KEY is just filename not full path`() { + val fullPath = "/data/user/0/com.example/databases/myapp.db" + val driver = fixture.setupDriver(fullPath) + val connection = driver.open(fullPath) + + val txOptions = TransactionOptions().apply { isBindToScope = true } + val tx = Sentry.startTransaction("name", "op", txOptions) as SentryTracer + connection.prepare("SELECT 1").step() + tx.finish() + + val span = tx.children.firstOrNull() + assertNotNull(span) + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("myapp.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt new file mode 100644 index 00000000000..df742bad900 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -0,0 +1,270 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.util.thread.IThreadChecker +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteStatementTest { + + private class Fixture { + val scopes = mock() + val mockStatement = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + private lateinit var spanRecorder: SQLiteSpanRecorder + + fun getSut(sql: String, isSpanActive: Boolean = true): SentrySQLiteStatement { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + spanRecorder = SQLiteSpanRecorder(scopes) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + if (isSpanActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + return SentrySQLiteStatement(mockStatement, spanRecorder, sql) + } + } + + private val fixture = Fixture() + + @Test + fun `step creates a span when active span exists`() { + val sut = fixture.getSut("SELECT 1") + assertEquals(0, fixture.sentryTracer.children.size) + sut.step() + val span = fixture.sentryTracer.children.firstOrNull() + assertSpanCreated("SELECT 1", span, SpanStatus.OK) + } + + @Test + fun `step does not create a span when no active span`() { + val sut = fixture.getSut("SELECT 1", isSpanActive = false) + sut.step() + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `multiple step calls for multi-row query create only one span`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + sut.step() + sut.step() + assertEquals(1, fixture.sentryTracer.children.size) + } + + @Test + fun `span is not emitted until iteration completes`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + assertEquals(0, fixture.sentryTracer.children.size) + sut.step() + assertEquals(0, fixture.sentryTracer.children.size) + sut.step() + assertEquals(1, fixture.sentryTracer.children.size) + } + + @Test + fun `close emits span for partially iterated query`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + assertEquals(0, fixture.sentryTracer.children.size) + sut.close() + assertEquals(1, fixture.sentryTracer.children.size) + assertSpanCreated("SELECT * FROM users", fixture.sentryTracer.children[0], SpanStatus.OK) + } + + @Test + fun `close without step does not emit span`() { + val sut = fixture.getSut("SELECT 1") + sut.close() + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `reset then step creates a new span`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + assertEquals(1, fixture.sentryTracer.children.size) + sut.reset() + sut.step() + assertEquals(2, fixture.sentryTracer.children.size) + } + + @Test + fun `reset emits span for in-progress iteration`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + sut.step() + assertEquals(0, fixture.sentryTracer.children.size) + sut.reset() + assertEquals(1, fixture.sentryTracer.children.size) + } + + @Test + fun `step that throws an exception creates INTERNAL_ERROR span with throwable attached`() { + val sut = fixture.getSut("BAD SQL") + val exception = RuntimeException("db error") + whenever(fixture.mockStatement.step()).thenThrow(exception) + assertFailsWith { sut.step() } + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("BAD SQL", span.description) + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + assertTrue(span.isFinished) + } + + @Test + fun `step after exception allows re-step creating a new span`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()) + .thenThrow(RuntimeException("first failure")) + .thenReturn(false) + assertFailsWith { sut.step() } + assertEquals(1, fixture.sentryTracer.children.size) + sut.step() + assertEquals(2, fixture.sentryTracer.children.size) + val secondSpan = fixture.sentryTracer.children[1] + assertEquals(SpanStatus.OK, secondSpan.status) + assertTrue(secondSpan.isFinished) + } + + @Test + fun `exception mid-iteration emits span covering all steps`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()) + .thenReturn(true) + .thenReturn(true) + .thenThrow(RuntimeException("disk error")) + sut.step() + sut.step() + assertEquals(0, fixture.sentryTracer.children.size) + assertFailsWith { sut.step() } + assertEquals(1, fixture.sentryTracer.children.size) + val span = fixture.sentryTracer.children[0] + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + } + + @Test + fun `all delegation methods are called on delegate`() { + val sut = fixture.getSut("SELECT 1") + + sut.reset() + verify(fixture.mockStatement).reset() + + sut.close() + verify(fixture.mockStatement).close() + + sut.getColumnName(0) + verify(fixture.mockStatement).getColumnName(0) + + sut.getColumnCount() + verify(fixture.mockStatement).getColumnCount() + + sut.isNull(0) + verify(fixture.mockStatement).isNull(0) + + sut.getLong(0) + verify(fixture.mockStatement).getLong(0) + + sut.getDouble(0) + verify(fixture.mockStatement).getDouble(0) + + sut.getText(0) + verify(fixture.mockStatement).getText(0) + + sut.bindNull(0) + verify(fixture.mockStatement).bindNull(0) + + sut.bindLong(0, 1L) + verify(fixture.mockStatement).bindLong(0, 1L) + + sut.bindDouble(0, 1.0) + verify(fixture.mockStatement).bindDouble(0, 1.0) + + sut.bindText(0, "text") + verify(fixture.mockStatement).bindText(0, "text") + + sut.bindBlob(0, byteArrayOf()) + verify(fixture.mockStatement).bindBlob(0, byteArrayOf()) + + sut.clearBindings() + verify(fixture.mockStatement).clearBindings() + } + + @Test + fun `BLOCKED_MAIN_THREAD_KEY is set on the span`() { + val sut = fixture.getSut("SELECT 1") + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") + + sut.step() + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + } + + @Test + fun `BLOCKED_MAIN_THREAD_KEY is true when running on main thread`() { + val sut = fixture.getSut("SELECT 1") + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") + + sut.step() + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + } + + @Test + fun `span duration reflects accumulated DB time not wall clock`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + Thread.sleep(50) + sut.step() + Thread.sleep(50) + sut.step() + + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + val startNanos = span.startDate.nanoTimestamp() + val endNanos = span.finishDate!!.nanoTimestamp() + val durationMs = (endNanos - startNanos) / 1_000_000 + assertTrue( + durationMs < 5, + "Span duration ${durationMs}ms should reflect near-zero mock step() time, not wall clock", + ) + } + + private fun assertSpanCreated(sql: String, span: ISpan?, status: SpanStatus) { + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals(sql, span.description) + assertEquals(status, span.status) + assertTrue(span.isFinished) + } +} From e64644427be1f8ca3ffe8af50761f71d6040e4f7 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 22 May 2026 17:11:27 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a98c0865f..2cab02512d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add `SentrySQLiteDriver` for Room 2.7+ `SQLiteDriver` instrumentation in `sentry-android-sqlite` +- 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))