diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySQLiteDriver.kt new file mode 100644 index 00000000000..4eaa0ac417a --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySQLiteDriver.kt @@ -0,0 +1,90 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import io.sentry.android.sqlite.SQLiteSpanManager +import io.sentry.IScopes +import io.sentry.ScopesAdapter + + +/** + * Automatically adds a Sentry span to the current scope for each database query executed. + * + * Usage - wrap this around your current [SQLiteDriver]: + * ``` + * val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver()) + * ``` + * + * If you use Room you can wrap the default [AndroidSQLiteDriver]: + * ``` + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver())) + * ... + * .build() + * ``` + */ +public class SentrySQLiteDriver internal constructor( + private val delegate: SQLiteDriver, + private val scopes: IScopes = ScopesAdapter.getInstance(), +) : SQLiteDriver { + override fun open(fileName: String): SQLiteConnection { + val sqliteSpanManager = SQLiteSpanManager( + scopes, + // SQLiteDriver.open docs say: + // >> To open an in-memory database use the special name :memory: as the fileName. + // SQLiteSpanManager expects null for an in-memory databaseName, so replace ":memory:" with null. + databaseName = fileName.takeIf { it != ":memory:" } + ) + val connection = delegate.open(fileName) + return SentrySQLiteConnection(connection, sqliteSpanManager) + } + + public companion object { + /** + * @param delegate The [SQLiteDriver] instance to delegate calls to. + */ + @JvmStatic + public fun create(delegate: SQLiteDriver): SQLiteDriver { + if (delegate is SentrySQLiteDriver) { + return delegate + } else { + return SentrySQLiteDriver(delegate) + } + } + } +} + +internal class SentrySQLiteConnection( + private val delegate: SQLiteConnection, + private val sqliteSpanManager: SQLiteSpanManager, +) : SQLiteConnection by delegate { + override fun prepare(sql: String): SQLiteStatement { + val statement = delegate.prepare(sql) + return SentrySQLiteStatement(statement, sqliteSpanManager, sql) + } +} + +internal class SentrySQLiteStatement( + private val delegate: SQLiteStatement, + private val sqliteSpanManager: SQLiteSpanManager, + private val sql: String, +) : SQLiteStatement by delegate { + // We have to start the span only the first time, regardless of how many times its methods get + // called. + private var isSpanStarted = false + + override fun step(): Boolean { + if (isSpanStarted) { + return delegate.step() + } else { + isSpanStarted = true + return sqliteSpanManager.performSql(sql) { delegate.step() } + } + } + + override fun reset() { + isSpanStarted = false + delegate.reset() + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySQLiteDriverTest.kt new file mode 100644 index 00000000000..423b959ca5a --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySQLiteDriverTest.kt @@ -0,0 +1,107 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SentrySQLiteDriverTest { + private class Fixture { + private val scopes = mock() + val mockDriver = mock() + val mockConnection = mock() + val mockStatement = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut(): SentrySQLiteDriver { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + whenever(scopes.span).thenReturn(sentryTracer) + + return SentrySQLiteDriver(mockDriver, scopes) + } + } + + private val fixture = Fixture() + + @Test + fun `opening connection and running query logs to Sentry`() { + val driver = fixture.getSut() + val sql = "SELECT * FROM users" + + whenever(fixture.mockDriver.open("test.db")).thenReturn(fixture.mockConnection) + whenever(fixture.mockConnection.prepare(sql)).thenReturn(fixture.mockStatement) + whenever(fixture.mockStatement.step()).thenReturn(true) + + // Open connection, prepare statement, and execute + val connection = driver.open("test.db") + val statement = connection.prepare(sql) + statement.step() + + // Verify span was created + assertEquals(1, fixture.sentryTracer.children.size) + val span = fixture.sentryTracer.children.first() + assertEquals("db.sql.query", span.operation) + assertEquals(sql, span.description) + } + + @Test + fun `on-disk database sets db name in span`() { + val driver = fixture.getSut() + val databaseName = "myapp.db" + val sql = "INSERT INTO users VALUES (1, 'test')" + + whenever(fixture.mockDriver.open(databaseName)).thenReturn(fixture.mockConnection) + whenever(fixture.mockConnection.prepare(sql)).thenReturn(fixture.mockStatement) + whenever(fixture.mockStatement.step()).thenReturn(true) + + // Open connection, prepare statement, and execute + val connection = driver.open(databaseName) + val statement = connection.prepare(sql) + statement.step() + + // Verify span was created with correct database name + assertEquals(1, fixture.sentryTracer.children.size) + val span = fixture.sentryTracer.children.first() + assertEquals("db.sql.query", span.operation) + assertEquals(sql, span.description) + assertEquals("sqlite", span.getData(SpanDataConvention.DB_SYSTEM_KEY)) + assertEquals(databaseName, span.getData(SpanDataConvention.DB_NAME_KEY)) + } + + @Test + fun `in-memory database sets db system to in-memory`() { + val driver = fixture.getSut() + val sql = "CREATE TABLE temp (id INT)" + + whenever(fixture.mockDriver.open(":memory:")).thenReturn(fixture.mockConnection) + whenever(fixture.mockConnection.prepare(sql)).thenReturn(fixture.mockStatement) + whenever(fixture.mockStatement.step()).thenReturn(true) + + // Open in-memory connection, prepare statement, and execute + val connection = driver.open(":memory:") + val statement = connection.prepare(sql) + statement.step() + + // Verify span was created with correct database system + assertEquals(1, fixture.sentryTracer.children.size) + val span = fixture.sentryTracer.children.first() + assertEquals("db.sql.query", span.operation) + assertEquals(sql, span.description) + assertEquals("in-memory", span.getData(SpanDataConvention.DB_SYSTEM_KEY)) + // DB_NAME_KEY should not be set for in-memory databases + assertNotNull(span.getData(SpanDataConvention.DB_SYSTEM_KEY)) + assertEquals(null, span.getData(SpanDataConvention.DB_NAME_KEY)) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySQLiteStatementTest.kt new file mode 100644 index 00000000000..a5664b97212 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySQLiteStatementTest.kt @@ -0,0 +1,147 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteStatementTest { + private class Fixture { + private val scopes = mock() + private val spanManager = SQLiteSpanManager(scopes) + val mockStatement = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut(sql: String, isSpanActive: Boolean = true): SentrySQLiteStatement { + 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 SentrySQLiteStatement(mockStatement, spanManager, sql) + } + } + + private val fixture = Fixture() + + @Test + fun `all calls are propagated to the delegate`() { + val sql = "SELECT * FROM users" + val statement = fixture.getSut(sql) + + whenever(fixture.mockStatement.step()).thenReturn(true) + + inOrder(fixture.mockStatement) { + statement.step() + verify(fixture.mockStatement).step() + + statement.reset() + verify(fixture.mockStatement).reset() + } + } + + @Test + fun `step creates a span if a span is running`() { + val sql = "SELECT * FROM users" + val sut = fixture.getSut(sql) + whenever(fixture.mockStatement.step()).thenReturn(true) + assertEquals(0, fixture.sentryTracer.children.size) + sut.step() + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `step does not create a span if no span is running`() { + val sql = "SELECT * FROM users" + val sut = fixture.getSut(sql, isSpanActive = false) + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `multiple step calls only create one span`() { + val sql = "SELECT * FROM users" + val sut = fixture.getSut(sql) + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + assertEquals(0, fixture.sentryTracer.children.size) + + // First step creates a span + sut.step() + assertEquals(1, fixture.sentryTracer.children.size) + + // Second step doesn't create a new span + sut.step() + assertEquals(1, fixture.sentryTracer.children.size) + + // Third step still doesn't create a new span + sut.step() + assertEquals(1, fixture.sentryTracer.children.size) + + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `reset allows step to create a new span`() { + val sql = "SELECT * FROM users" + val sut = fixture.getSut(sql) + whenever(fixture.mockStatement.step()).thenReturn(true) + assertEquals(0, fixture.sentryTracer.children.size) + + // First step creates a span + sut.step() + assertEquals(1, fixture.sentryTracer.children.size) + + // Reset the statement + sut.reset() + + // Next step creates a new span + sut.step() + assertEquals(2, fixture.sentryTracer.children.size) + + // Verify both spans were created correctly + fixture.sentryTracer.children.forEach { span -> + assertSqlSpanCreated(sql, span) + } + } + + @Test + fun `step returns delegate result`() { + val sql = "SELECT * FROM users" + val sut = fixture.getSut(sql) + whenever(fixture.mockStatement.step()).thenReturn(true, false) + + val result1 = sut.step() + assertTrue(result1) + + sut.reset() + + val result2 = sut.step() + assertFalse(result2) + } + + private fun assertSqlSpanCreated(sql: String, span: ISpan?) { + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals(sql, span.description) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } +}