Skip to content

feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275)#5466

Open
0xadam-brown wants to merge 3 commits into
mainfrom
feat/support-sqlite-driver
Open

feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275)#5466
0xadam-brown wants to merge 3 commits into
mainfrom
feat/support-sqlite-driver

Conversation

@0xadam-brown
Copy link
Copy Markdown
Member

@0xadam-brown 0xadam-brown commented May 22, 2026

📜 Description

Introduces SentrySQLiteDriver for wrapping and instrumenting androidx.sqlite.SQLiteDriver. Like our existing SentrySupportSQLiteOpenHelper, the new wrapper produces a span per executed SQL statement.

Example use:

  Room.databaseBuilder(context, AppDatabase::class.java, "myapp.db")
      .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver()))
      .build()

💡 Motivation and Context

Room 2.7 introduced the SQLiteDriver API as a replacement for SupportSQLiteOpenHelper; Room 3.0+ makes use of SQLiteDriver mandatory.

A key motivation behind Google's introduction of SQLiteDriver was Kotlin Multiplatform compatibility. This PR makes SentrySQLiteDriver available on Android only, but the wrapper has been packaged so that we can lift it into our KMP module in the future without having to break clients.

Addresses JAVA-275.

⚠️ Callouts

[1] Unlike SentrySupportSQLiteOpenHelper, the SentrySQLiteDriver is not automatically wrapped via the sentry-android-gradle-plugin. (We can add byte code support later if we want – or do it now if there's a strong interest.)

[2] API is not marked as @Experimental. (Super small surface area: a create(SQLiteDriver) constructor; future additions are non-breaking; sufficient alignment with SentrySupportSQLiteOpenHelper data model. That said, chime in if you think we should add it.)

[3] Behavior differences vs SentrySupportSQLiteHelper

Click to expand

Behavior differences

Aspect SentrySupportSQLiteOpenHelper (old) SentrySQLiteDriver (new) Evaluation
Span duration Whole performSql call (incl. app time during cursor materialization) Accumulated step() db time only – app time between steps excluded New is more accurate. Span duration is now limited to SQLite work, not the read loop plus arbitrary app-side row processing, I/O, or GC pauses between rows. Cleaner p95s for db performance dashboards; teams using span duration as a proxy for end-to-end read time will see it shrink.
Cursor iteration Only first getCount/onMove/fillWindow timed; later rows untimed Every step() contributes to cumulative span time New is more complete. A 10k-row read now reflects the full cumulative db cost rather than just the first window fill. The old path silently under-reported large reads.
Span wall-clock anchor Captured before the operation runs Captured at first step() of the cycle Visually, new span sits slightly later in trace New anchor tracks when SQLite actually started working; old anchor included setup/dispatch overhead in the span timeline.
db.name derivation Reads open helper databaseName (for Room, the builder name, e.g., "tracks" from databaseBuilder(ctx, MyDb, "tracks")). Reads File(fileName).name — the on-disk filename of the path Room passes to driver.open() (e.g., "tracks.db"). The same db can show up under two different db.name values during migration. Both paths report data correctly, but will attribute it to different sources.
Multi-statement scripts execSQL("CREATE TABLE …; INSERT …; INSERT …;") produces one span whose description is the full script. The Driver API compiles one statement per prepare(...), so multi-statement scripts must be split by Room (or the caller) into separate prepare/step cycles → one span per statement. New is more accurate but more verbose. Migration scripts and seed scripts that previously appeared as one bundled span will now appear as N smaller spans, each with its own timing and description. Useful for finding the slow statement; expect span counts to rise for these code paths.

[4] Risk of duplicate spans with Room's bridge adapter

SentrySQLiteDriver protects internally against duplicate span creation should a developer try to double-wrap it or any of its components. Room and SQLDelight ensure that use of the driver and SentrySupportSQLiteOpenHelper are mutually exclusive at the API level in virtually all instances, save for one:

Room 2.7+ (but not Room 3.0+) exposes a bridge adapter (SupportSQLiteDriver) that lets users delegate to a SQLiteDriver from an existing SupportSQLiteOpenHelper. Double-wrapping both components is possible if folks aren't mindful.

I'll be updating the Sentry Docs to warn users against wrapping both adapter components. Another option is to log an error, as mentioned here.

Updates to Sentry Docs: Click to expand

Avoiding duplicate spans with Room 2.7+

AndroidX ships an adapter class, SupportSQLiteDriver, that lets developers bridge an existing SupportSQLiteOpenHelper to a SQLiteDriver that Room 2.7+ accepts. Do not wrap both the open helper and the driver. (Remember that the Sentry Android Gradle Plugin will wrap the open helper for you at the byte code level if enabled.) If you double-wrap, you'll produce duplicate spans for every SQL statement:

// AVOID — this configuration produces duplicate spans for every SQL statement.

// Step 1: Wrap the open helper manually or via the Sentry Android Gradle Plugin.
val sentryWrappedHelper: SupportSQLiteOpenHelper =
    SentrySupportSQLiteOpenHelper.create(
        FrameworkSQLiteOpenHelperFactory().create(configuration)
    )

// Step 2: Pass the wrapped helper to the Room 2.7+ adapter.
val driver: SQLiteDriver = SupportSQLiteDriver(sentryWrappedHelper)

// Step 3: Also (wrongly!) wrap the driver. All spans will now be duplicated.
val sentryWrappedDriver: SQLiteDriver = SentrySQLiteDriver.create(driver)

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

💚 How did you test it?

Unit tests cover:

  • SentrySQLiteDriver delegation and wrapping
  • SentrySQLiteConnection delegation and wrapping
  • SentrySQLiteStatement span creation, cancellation, and success/error tagging
  • SQLiteSpanRecorder span lifecycle (start/finish/cancel)

I also dog-fooded SentrySQLiteDriver on my own example app via Maven local artifact + I verified spans are displayed in Sentry UI.

⚠️ Possibilities I did without:

  1. Testing against a real Room db in this PR (I did so manually in my example app, but this PR doesn't include a test that wires up Room)
  2. Instrumentation tests
  3. Add a sample to sentry-samples-android (not really a test, but it would exercise the code paths in an actual Android environment).

The legacy open helper didn't have the above, so I followed suit. Let me know if you think any is worth it and I'll be happy to address (eg, real Room db test, which would require a test dependency on androidx.sqlite:sqlite-bundled but wouldn't require Robolectric).

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

  1. Update the Room & SQLite Sentry docs.
  2. If there's sufficient demand, we could support auto-wrapping SQLiteDriver via the sentry-android-gradle-plugin at some point (not currently on the roadmap).

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 22, 2026

JAVA-275

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 02edd2a

@sentry
Copy link
Copy Markdown

sentry Bot commented May 22, 2026

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.43.0 (1) release

⚙️ sentry-android Build Distribution Settings

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 350.72 ms 415.76 ms 65.03 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
ad8da22 365.86 ms 427.00 ms 61.14 ms
abfcc92 337.38 ms 427.39 ms 90.00 ms
b8bd880 314.56 ms 336.50 ms 21.94 ms
44472da 313.96 ms 365.35 ms 51.39 ms
b03edbb 352.20 ms 423.69 ms 71.49 ms
f6cdbf0 314.19 ms 357.59 ms 43.40 ms
f064536 329.00 ms 395.62 ms 66.62 ms
806307f 357.85 ms 424.64 ms 66.79 ms
d15471f 343.13 ms 361.47 ms 18.34 ms
6edfca2 316.43 ms 398.90 ms 82.46 ms

App size

Revision Plain With Sentry Diff
ad8da22 1.58 MiB 2.29 MiB 719.83 KiB
abfcc92 1.58 MiB 2.13 MiB 557.31 KiB
b8bd880 1.58 MiB 2.29 MiB 722.92 KiB
44472da 0 B 0 B 0 B
b03edbb 1.58 MiB 2.13 MiB 557.32 KiB
f6cdbf0 0 B 0 B 0 B
f064536 1.58 MiB 2.20 MiB 633.90 KiB
806307f 1.58 MiB 2.10 MiB 533.42 KiB
d15471f 1.58 MiB 2.13 MiB 559.54 KiB
6edfca2 1.58 MiB 2.13 MiB 559.07 KiB

Previous results on branch: feat/support-sqlite-driver

Startup times

Revision Plain With Sentry Diff
2a55b58 365.41 ms 434.64 ms 69.23 ms
f8d2380 309.09 ms 365.52 ms 56.43 ms
4993a1b 303.45 ms 392.65 ms 89.20 ms

App size

Revision Plain With Sentry Diff
2a55b58 0 B 0 B 0 B
f8d2380 0 B 0 B 0 B
4993a1b 0 B 0 B 0 B

@0xadam-brown 0xadam-brown force-pushed the feat/support-sqlite-driver branch 2 times, most recently from a590992 to 0084312 Compare May 28, 2026 07:37
Introduces support for AndroidX's SQLiteDriver via a new SentrySQLiteDriver wrapper.

SentrySQLiteDriver automatically creates spans for each SQL statement it executes, and its data scheme closely tracks that of SentrySupportSQLiteOpenHelper, which it's designed to replace. (Span duration is an important exception; see the SentrySQLiteStatement KDoc for more details.)

A key motivation for Google's using SQLiteDriver with Room 2.7+ was Kotlin Multiplatform support. We've been careful to keep the SentrySQLiteDriver KMP-compatible as well, should we one day want to lift it into sentry-kotlin-multiplatform.

---

Co-authored-by: Angus Holder <7407345+angusholder@users.noreply.github.com>
@0xadam-brown 0xadam-brown force-pushed the feat/support-sqlite-driver branch from 4c87cb9 to 4c67ea9 Compare May 28, 2026 11:19
Comment thread sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt Outdated
@0xadam-brown 0xadam-brown marked this pull request as ready for review May 28, 2026 11:56
Comment thread sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt Outdated
Method reference `System::nanoTime` compiles to FunctionReferenceImpl, which breaks R8 in the SDK size test app.
Copy link
Copy Markdown
Member

@markushi markushi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very promising already, left a few comments. Once we've decided what execution phases spans should cover, I'll have a second look.

} catch (e: Throwable) {
span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)
span?.spanContext?.origin = TRACE_ORIGIN
span = spanHelper.startSpan(sql, startTimestamp)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait, I think this doesn't make sense, or does it? If an sql operation fails we start a new span... for throwing an exception? I guess this was overlooked, we should drop this 😅

Copy link
Copy Markdown
Member

@romtsn romtsn Jun 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, dammit it had been introduced during merge conflict resolution. We should def. drop this

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I actually think this was intentional. (It's been with us for a while and it's a pattern used elsewhere when an action that should produce a plain vanilla span throws instead, see eg sentry-jdbc, sentry-okhttp, etc.)

Note that spanHelper uses a lazy start pattern here, in that it's documenting the error span after the fact and immediately finishes the span in finally, rather than leaving it open.

So presumably we want to keep this? (+ the similar INTERNAL_ERROR span in the catch clause of the new SentrySQLiteStatement.span() method).

That said, let me know if I goofed and we really do want to strip the error span out.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, indeed, I missed that we're using the startTimestamp that we retrieved at the beginning of the method 🙈

I guess it reads a bit confusing (like we're starting the span inside catch {} as opposed to starting it before the sql op), I'd expect something like:

val span = spanHelper.startSpan(sql, now)
try {
  val result = operation()
  span?.status = SpanStatus.OK
  result
} catch (e: Throwable) {
  span?.status = INTERNAL_ERROR
  span?.throwable = e
} finally {
  ...
  span?.finish()
}

which is more natural. But the current approach is also fine by me 👍

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is due to my ambition to share span building logic btw the open helper and driver. I'll see whether I can't make the API more obvious.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to avoid splitting between startSpan() and finishSpan() calls. Now there's just one w/a (hopefully) less misleading name:

@@ -43,19 +41,11 @@ internal class SQLiteSpanManager(
       if (result is CrossProcessCursor) {
         return SentryCrossProcessCursor(result, this, sql) as T
       }
-      span = spanHelper.startSpan(sql, startTimestamp)
-      span?.status = SpanStatus.OK
+      spans.recordSpan(sql, startTimestamp, SpanStatus.OK)
       result
     } catch (e: Throwable) {
-      span = spanHelper.startSpan(sql, startTimestamp)
-      span?.status = SpanStatus.INTERNAL_ERROR
-      span?.throwable = e
+      spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e)
       throw e
-    } finally {
-      span?.let {
-        spanHelper.applyDataToSpan(it)
-        it.finish()
-      }
     }
   }
 }

Comment thread sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like that you've tried to split responsibilities here, but was wondering if we could somehow reduce the number of helper classes? 😅 We now have a Manager, a Recorder and a Helper -- that's too many "ers" for my taste, haha, and it's a bit difficult to make a distinction between them.

I understand that it might be not possible due to backwards-compat with the existing sqlite instrumentation, but perhaps there's a way?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two options (chime in if you prefer a third):

  1. Merge SQLiteSpanHelper into SQLiteSpanRecorder at the cost of making the resulting API wider than it needs to be for either consumer.
  2. Do (1) but no longer share logic across the .android.sqlite and .sqlite packages (lets us keep the SQLiteSpanRecorder API tailored to its now sole consumer).

I'll do (1), see whether (2) calls to me, and then make a decision on my end. Feel free to wait for me to post my next commit if you don't have a strong opinion in the meantime....

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did (1) and it was able to tailor the API appropriately to each consumer by overloading a single recordSpan() method. Satisfied on my end. Let me know if you'd like any additional changes.

Comment thread sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanRecorder.kt Outdated
Copy link
Copy Markdown
Member

@romtsn romtsn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, nice analysis and research, kudos!

Some more things I'd want to clarify before approving:

  • do we actually need to bump androidx.sqlite version that we compile against in the module to support this new stuff?
  • what's the migration path? do we want to keep them side-by-side in the same module, or would it make sense to introduce sentry-android-sqlite3 (or whatever fits best) to keep them separate? would probably also simplify the future KMP work (if ever)
  • you said you're yet to test it out with SAGP and auto-instrumentation, which is great! I think I would want to also make SAGP instrumentation of the new Driver a part of the success criteria, because this is the main method for our users to get room/sqlite instrumented (obviously it doesn't have to block this PR)

@0xadam-brown
Copy link
Copy Markdown
Member Author

0xadam-brown commented Jun 3, 2026

Thanks for the excellent feedback / comments @romtsn. Answers to your questions + one question about single vs multiple modules on my end...

> do we actually need to bump androidx.sqlite version that we compile against in the module to support this new stuff?

Not for this PR: we're already on androidx.sqlite:sqlite:2.5.2 and SQLiteDriver landed in 2.5.0.

But we will need to bump to 2.7.0 in my follow-on PR for fixing the Room 3.0-alpha error discussed offline (error is caused by how D8 de-sugars the recently-introduced SQLiteDriver.hasConnectionPool() method on Android SDK 23 devices).

Fyi, I'll wait to merge all the PRs together so we make sure they land in the same release.

> what's the migration path? do we want to keep them side-by-side in the same module, or would it make sense to introduce usentry-android-sqlite3 (or whatever fits best) to keep them separate? would probably also simplify the future KMP work (if ever)

This ended up being an important question (thx!). I propose keeping the driver in the existing sentry-android-sqlite module (see discussion below).

Here are the migration steps I had in mind:

  1. Namespace the driver in a way that lets it (eventually) be lifted into sentry-kotlin-multiplatform + keep its public ABI compatible with KMP common. (Already completed via this PR.)
  2. Prepare for lifting (e.g., update driver to use TimeSource rather than System.nanoTime(); have someone create KMP common versions of ISpan, etc. – this latter work is the real blocker atm).
  3. Lift all of io.sentry.sqlite into a new sentry-kotlin-platform module (eg, sentry-kmp-sqlite) without changing its public ABI.
  4. Remove the driver from sentry-android-sqlite (or a new sentry-sqlite module if we go that route) + update the existing module's build.gradle to pull in the driver so consumers don't have to change their coordinate save for bumping the version:
// build.gradle in sentry-android-sqlite (if the driver lives with the open helper) or 
// sentry-sqlite (if we give the driver its own module)
dependencies {
    api("io.sentry:sentry-kmp-sqlite") 
}

Creating a new module doesn't meaningfully simplify future KMP work because we'll always have to relocate the driver somewhere + redirect from the existing module's build.gradle script. (Eg, if we want to publish the driver in a JAR for non-Android JVM consumers / desktop, we could create a new module then and redirect from sentry-android-sqlite; if we want to go straight for common KMP, we do likewise, just in sentry-kotlin-multiplatform.) See the expandable "migration path to KMP" discussion below for more details.

sentry-compose seems reasonable prior art here, and it doesn't have a separate module for its KMP-compatible code. So my vote would be to keep the driver in sentry-android-sqlite for now to aid open helper -> driver migration, and then break it out if and when the need arises.

cc @markushi

Step-by-step migration path to KMP: Click to expand

Migration path

# Step Path A: Use existing module Path B: Create sentry-sqlite module now
1 Untangle shared helpers (SQLiteSpanHelper, DbMetadata) from the open helper path Must do - trivial Already done
2 Isolate driver + shared code into a standalone, Android-free compilation unit with its own coordinate Must do - this is the module split itself Already done
3 Relocate driver into sentry-kotlin-multiplatform (new module/source set there) Required Required
4 Extend the KMP SDK's common API to cover what the wrapper needs: child span with explicit start/finish timestamps, setData (db.system/db.name/blocked_main_thread/call-stack), span status, trace origin, scope access, integration registration Required Required
5 Rewrite the Sentry-touching code (SQLiteSpanHelper/SQLiteSpanRecorder/registration) against the KMP common API; drop all JVM-only io.sentry.* types (ISpan, IScopes, etc.) Required Required
6 Replace System.nanoTime() with a multiplatform monotonic clock (kotlin.time.TimeSource.Monotonic) Required Required
7 Convert build to kotlin.multiplatform, add targets, set up GMM publishing + .craft/release wiring in the sentry-kmp repo Required Required
8 Redirect the sentry-java artifact toward the new KMP coordinate; migration note for existing Android/JVM users Required Required

User experience

Path A Path B
Add today (typical Android user) Often zero new dep; familiar artifact A new, separate artifact (two during Room 2.7 transition)
Code today io.sentry.sqlite.SentrySQLiteDriver Identical
ProGuard / stack-trace fidelity Automatic (AAR) Manual META-INF/proguard — small risk of silent degradation
Off-Android JVM use Impossible Works
Greenfield driver-only classpath Carries unused open-helper code Cleaner (driver-only)

KMP migration shape for both paths is identical: no code change so long as we can keep the package name in sentry-kotlin-multiplatform; dependency redirect means only version bump needed on existing coordinate.

> you said you're yet to test it out with SAGP and auto-instrumentation, which is great! I think I would want to also make SAGP instrumentation of the new Driver a part of the success criteria, because this is the main method for our users to get room/sqlite instrumented (obviously it doesn't have to block this PR)

Good deal. I'll plan to implement SAGP auto-instrumentation of RoomDatabase.Builder.setDriver() as part of the current project. (I'll also double-check the behavior of our current auto-generated db.sql.room spans, and we can tweak if needed.)

 - Merge SQLiteSpanHelper + SQLiteSpanRecorder into a single SQLiteSpanInstrumentation class.
 - DRY out reference to file name path separators.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants