diff --git a/.gitignore b/.gitignore index d72b853788..92ea301ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ distributions/ *.vscode/ sentry-spring-boot-starter-jakarta/src/main/resources/META-INF/spring.factories sentry-samples/sentry-samples-spring-boot-jakarta/spy.log +spy.log diff --git a/CHANGELOG.md b/CHANGELOG.md index f172175fe2..d2bd9a3fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Features +- Introduce new `sentry-android-sqlite` integration ([#2722](https://github.com/getsentry/sentry-java/pull/2722)) + - This integration replaces the old `androidx.sqlite` database instrumentation in the Sentry Android Gradle plugin + - A new capability to manually instrument your `androidx.sqlite` databases. + - You can wrap your custom `SupportSQLiteOpenHelper` instance into `SentrySupportSQLiteOpenHelper(myHelper)` if you're not using the Sentry Android Gradle plugin and still benefit from performance auto-instrumentation. - Add SentryWrapper for Callable and Supplier Interface ([#2720](https://github.com/getsentry/sentry-java/pull/2720)) ## 6.20.0 diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 40d7b10934..6d54ef9296 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -57,6 +57,7 @@ object Config { val lifecycleProcess = "androidx.lifecycle:lifecycle-process:$lifecycleVersion" val lifecycleCommonJava8 = "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" val androidxCore = "androidx.core:core:1.3.2" + val androidxSqlite = "androidx.sqlite:sqlite:2.3.1" val androidxRecylerView = "androidx.recyclerview:recyclerview:1.2.1" val slf4jApi = "org.slf4j:slf4j-api:1.7.30" diff --git a/sentry-android-sqlite/api/sentry-android-sqlite.api b/sentry-android-sqlite/api/sentry-android-sqlite.api new file mode 100644 index 0000000000..c8780f1338 --- /dev/null +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -0,0 +1,23 @@ +public final class io/sentry/android/sqlite/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper : androidx/sqlite/db/SupportSQLiteOpenHelper { + public static final field Companion Lio/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Companion; + public synthetic fun (Landroidx/sqlite/db/SupportSQLiteOpenHelper;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public static final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper; + public fun getDatabaseName ()Ljava/lang/String; + public fun getReadableDatabase ()Landroidx/sqlite/db/SupportSQLiteDatabase; + public fun getWritableDatabase ()Landroidx/sqlite/db/SupportSQLiteDatabase; + public fun setWriteAheadLoggingEnabled (Z)V +} + +public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Companion { + public final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper; +} + diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts new file mode 100644 index 0000000000..11b895f385 --- /dev/null +++ b/sentry-android-sqlite/build.gradle.kts @@ -0,0 +1,87 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.config.KotlinCompilerVersion + +plugins { + id("com.android.library") + kotlin("android") + jacoco + id(Config.QualityPlugins.gradleVersions) + id(Config.QualityPlugins.detektPlugin) +} + +android { + compileSdk = Config.Android.compileSdkVersion + namespace = "io.sentry.android.sqlite" + + defaultConfig { + targetSdk = Config.Android.targetSdkVersion + minSdk = Config.Android.minSdkVersion + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + } + + buildTypes { + getByName("debug") + getByName("release") { + consumerProguardFiles("proguard-rules.pro") + } + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + variantFilter { + if (Config.Android.shouldSkipDebugVariant(buildType.name)) { + ignore = true + } + } +} + +tasks.withType { + configure { + isIncludeNoLocationClasses = false + } +} + +kotlin { + explicitApi() +} + +dependencies { + api(projects.sentry) + + compileOnly(Config.Libs.androidxSqlite) + + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + + // tests + testImplementation(Config.Libs.androidxSqlite) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.androidxJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) +} + +tasks.withType { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/sentry-android-sqlite/proguard-rules.pro b/sentry-android-sqlite/proguard-rules.pro new file mode 100644 index 0000000000..02ab589d3b --- /dev/null +++ b/sentry-android-sqlite/proguard-rules.pro @@ -0,0 +1,7 @@ +##---------------Begin: proguard configuration for SQLite ---------- + +# To ensure that stack traces is unambiguous +# https://developer.android.com/studio/build/shrink-code#decode-stack-trace +-keepattributes LineNumberTable,SourceFile + +##---------------End: proguard configuration for SQLite ---------- 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 new file mode 100644 index 0000000000..e91c35022a --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -0,0 +1,40 @@ +package io.sentry.android.sqlite + +import android.database.SQLException +import io.sentry.HubAdapter +import io.sentry.IHub +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SpanStatus + +internal class SQLiteSpanManager( + private val hub: IHub = HubAdapter.getInstance() +) { + + init { + SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") + } + + /** + * Performs a sql operation, creates a span and handles exceptions in case of occurrence. + * + * @param sql The sql query + * @param operation The sql operation to execute. + * In case of an error the surrounding span will have its status set to INTERNAL_ERROR + */ + @Suppress("TooGenericExceptionCaught") + @Throws(SQLException::class) + fun performSql(sql: String, operation: () -> T): T { + val span = hub.span?.startChild("db.sql.query", sql) + return try { + val result = operation() + span?.status = SpanStatus.OK + result + } catch (e: Throwable) { + span?.status = SpanStatus.INTERNAL_ERROR + span?.throwable = e + throw e + } finally { + span?.finish() + } + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt new file mode 100644 index 0000000000..4c944fb07d --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt @@ -0,0 +1,88 @@ +package io.sentry.android.sqlite + +import android.annotation.SuppressLint +import android.database.Cursor +import android.database.SQLException +import android.os.Build +import android.os.CancellationSignal +import androidx.annotation.RequiresApi +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.sqlite.db.SupportSQLiteStatement + +/** + * The Sentry's [SentrySupportSQLiteDatabase], it will automatically add a span + * out of the active span bound to the scope for each database query. + * It's a wrapper around [SupportSQLiteDatabase], and it's created automatically + * by the [SentrySupportSQLiteOpenHelper]. + * + * @param delegate The [SupportSQLiteDatabase] instance to delegate calls to. + * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + */ +internal class SentrySupportSQLiteDatabase( + private val delegate: SupportSQLiteDatabase, + private val sqLiteSpanManager: SQLiteSpanManager +) : SupportSQLiteDatabase by delegate { + + /** + * Compiles the given SQL statement. It will return Sentry's wrapper around SupportSQLiteStatement. + * + * @param sql The sql query. + * @return Compiled statement. + */ + override fun compileStatement(sql: String): SupportSQLiteStatement { + return SentrySupportSQLiteStatement(delegate.compileStatement(sql), sqLiteSpanManager, sql) + } + + @Suppress("AcronymName") // To keep consistency with framework method name. + override fun execPerConnectionSQL( + sql: String, + @SuppressLint("ArrayReturn") bindArgs: Array? + ) { + sqLiteSpanManager.performSql(sql) { + delegate.execPerConnectionSQL(sql, bindArgs) + } + } + + override fun query(query: String): Cursor { + return sqLiteSpanManager.performSql(query) { + delegate.query(query) + } + } + + override fun query(query: String, bindArgs: Array): Cursor { + return sqLiteSpanManager.performSql(query) { + delegate.query(query, bindArgs) + } + } + + override fun query(query: SupportSQLiteQuery): Cursor { + return sqLiteSpanManager.performSql(query.sql) { + delegate.query(query) + } + } + + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) + override fun query( + query: SupportSQLiteQuery, + cancellationSignal: CancellationSignal? + ): Cursor { + return sqLiteSpanManager.performSql(query.sql) { + delegate.query(query, cancellationSignal) + } + } + + @Throws(SQLException::class) + override fun execSQL(sql: String) { + sqLiteSpanManager.performSql(sql) { + delegate.execSQL(sql) + } + } + + @Throws(SQLException::class) + override fun execSQL(sql: String, bindArgs: Array) { + sqLiteSpanManager.performSql(sql) { + delegate.execSQL(sql, bindArgs) + } + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt new file mode 100644 index 0000000000..49c5b0f638 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt @@ -0,0 +1,65 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper + +/** + * The Sentry's [SentrySupportSQLiteOpenHelper], it will automatically add a span + * out of the active span bound to the scope for each database query. + * It's a wrapper around an instance of [SupportSQLiteOpenHelper]. + * + * You can wrap your custom [SupportSQLiteOpenHelper] instance with `SentrySupportSQLiteOpenHelper(myHelper)`. + * If you're using the Sentry Android Gradle plugin, this will be applied automatically. + * + * Usage - wrap your custom [SupportSQLiteOpenHelper] instance in [SentrySupportSQLiteOpenHelper] + * + * ``` + * val openHelper = SentrySupportSQLiteOpenHelper.create(myOpenHelper) + * ``` + * + * If you use Room you can wrap the default [FrameworkSQLiteOpenHelperFactory]: + * + * ``` + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .openHelperFactory { configuration -> + * SentrySupportSQLiteOpenHelper.create(FrameworkSQLiteOpenHelperFactory().create(configuration)) + * } + * ... + * .build() + * ``` + * + * @param delegate The [SupportSQLiteOpenHelper] instance to delegate calls to. + */ +class SentrySupportSQLiteOpenHelper private constructor( + private val delegate: SupportSQLiteOpenHelper +) : SupportSQLiteOpenHelper by delegate { + + private val sqLiteSpanManager = SQLiteSpanManager() + + private val sentryWritableDatabase: SupportSQLiteDatabase by lazy { + SentrySupportSQLiteDatabase(delegate.writableDatabase, sqLiteSpanManager) + } + + private val sentryReadableDatabase: SupportSQLiteDatabase by lazy { + SentrySupportSQLiteDatabase(delegate.readableDatabase, sqLiteSpanManager) + } + + override val writableDatabase: SupportSQLiteDatabase + get() = sentryWritableDatabase + + override val readableDatabase: SupportSQLiteDatabase + get() = sentryReadableDatabase + + companion object { + + // @JvmStatic is needed to let this method be accessed by our gradle plugin + @JvmStatic + fun create(delegate: SupportSQLiteOpenHelper): SupportSQLiteOpenHelper { + return if (delegate is SentrySupportSQLiteOpenHelper) { + delegate + } else { + SentrySupportSQLiteOpenHelper(delegate) + } + } + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt new file mode 100644 index 0000000000..b69de2c48e --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt @@ -0,0 +1,50 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.db.SupportSQLiteStatement + +/** + * The Sentry's [SentrySupportSQLiteStatement], it will automatically add a span + * out of the active span bound to the scope when it is executed. + * It's a wrapper around an instance of [SupportSQLiteStatement], and it's created automatically + * by [SentrySupportSQLiteDatabase.compileStatement]. + * + * @param delegate The [SupportSQLiteStatement] instance to delegate calls to. + * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param sql The query string. + */ +internal class SentrySupportSQLiteStatement( + private val delegate: SupportSQLiteStatement, + private val sqLiteSpanManager: SQLiteSpanManager, + private val sql: String +) : SupportSQLiteStatement by delegate { + + override fun execute() { + return sqLiteSpanManager.performSql(sql) { + delegate.execute() + } + } + + override fun executeUpdateDelete(): Int { + return sqLiteSpanManager.performSql(sql) { + delegate.executeUpdateDelete() + } + } + + override fun executeInsert(): Long { + return sqLiteSpanManager.performSql(sql) { + delegate.executeInsert() + } + } + + override fun simpleQueryForLong(): Long { + return sqLiteSpanManager.performSql(sql) { + delegate.simpleQueryForLong() + } + } + + override fun simpleQueryForString(): String? { + return sqLiteSpanManager.performSql(sql) { + delegate.simpleQueryForString() + } + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt new file mode 100644 index 0000000000..5df0d891f9 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt @@ -0,0 +1,84 @@ +package io.sentry.android.sqlite + +import android.database.SQLException +import io.sentry.IHub +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SQLiteSpanManagerTest { + + private class Fixture { + private val hub = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut(isSpanActive: Boolean = true): SQLiteSpanManager { + options = SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + } + whenever(hub.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + + if (isSpanActive) { + whenever(hub.span).thenReturn(sentryTracer) + } + return SQLiteSpanManager(hub) + } + } + + private val fixture = Fixture() + + @Test + fun `add SQLite to the list of integrations`() { + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLite")) + fixture.getSut() + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLite")) + } + + @Test + fun `performSql creates a span if a span is running`() { + val sut = fixture.getSut() + sut.performSql("sql") {} + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("sql", span.description) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `performSql does not create a span if no span is running`() { + val sut = fixture.getSut(isSpanActive = false) + sut.performSql("sql") {} + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `performSql creates a span with error status if the operation throws`() { + val sut = fixture.getSut() + val e = SQLException() + try { + sut.performSql("error sql") { + throw e + } + } catch (_: Throwable) {} + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("error sql", span.description) + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(e, span.throwable) + assertTrue(span.isFinished) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt new file mode 100644 index 0000000000..cf22c3b0ec --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt @@ -0,0 +1,236 @@ +package io.sentry.android.sqlite + +import android.database.Cursor +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteQuery +import io.sentry.IHub +import io.sentry.ISpan +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SentrySupportSQLiteDatabaseTest { + + private class Fixture { + private val hub = mock() + private val spanManager = SQLiteSpanManager(hub) + val mockDatabase = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + init { + whenever(mockDatabase.compileStatement(any())).thenReturn(mock()) + } + + fun getSut(isSpanActive: Boolean = true): SentrySupportSQLiteDatabase { + options = SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + } + whenever(hub.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + + if (isSpanActive) { + whenever(hub.span).thenReturn(sentryTracer) + } + + return SentrySupportSQLiteDatabase(mockDatabase, spanManager) + } + } + + private val fixture = Fixture() + + @Test + fun `all calls are propagated to the delegate`() { + val sql = "sql" + val dummySqLiteQuery = mock() + whenever(dummySqLiteQuery.sql).thenReturn(sql) + val sut = fixture.getSut() + + inOrder(fixture.mockDatabase) { + sut.compileStatement(sql) + verify(fixture.mockDatabase).compileStatement(eq(sql)) + + sut.execPerConnectionSQL(sql, emptyArray()) + verify(fixture.mockDatabase).execPerConnectionSQL(eq(sql), any()) + + var res = sut.query(sql) + verify(fixture.mockDatabase).query(eq(sql)) + assertIs(res) + + res = sut.query(sql, emptyArray()) + verify(fixture.mockDatabase).query(eq(sql), any()) + assertIs(res) + + res = sut.query(dummySqLiteQuery) + verify(fixture.mockDatabase).query(eq(dummySqLiteQuery)) + assertIs(res) + + res = sut.query(dummySqLiteQuery, mock()) + verify(fixture.mockDatabase).query(eq(dummySqLiteQuery), any()) + assertIs(res) + + sut.execSQL(sql) + verify(fixture.mockDatabase).execSQL(eq(sql)) + + sut.execSQL(sql, emptyArray()) + verify(fixture.mockDatabase).execSQL(eq(sql), any()) + + sut.execPerConnectionSQL(sql, emptyArray()) + verify(fixture.mockDatabase).execPerConnectionSQL(eq(sql), any()) + } + } + + @Test + fun `compileStatement returns a SentrySupportSQLiteStatement`() { + val sut = fixture.getSut() + val compiled = sut.compileStatement("sql") + assertNotNull(compiled) + assertIs(compiled) + } + + @Test + fun `execPerConnectionSQL creates a span if a span is running`() { + val sql = "execPerConnectionSQL" + val sut = fixture.getSut() + assertEquals(0, fixture.sentryTracer.children.size) + sut.execPerConnectionSQL(sql, emptyArray()) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `execPerConnectionSQL does not create a span if no span is running`() { + val sut = fixture.getSut(isSpanActive = false) + sut.execPerConnectionSQL("execPerConnectionSQL", emptyArray()) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `query creates a span if a span is running`() { + val sql = "query" + val sut = fixture.getSut() + assertEquals(0, fixture.sentryTracer.children.size) + sut.query(sql) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `query does not create a span if no span is running`() { + val sut = fixture.getSut(isSpanActive = false) + sut.query("query") + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `query with bindArgs creates a span if a span is running`() { + val sql = "query" + val sut = fixture.getSut() + assertEquals(0, fixture.sentryTracer.children.size) + sut.query(sql, emptyArray()) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `query with bindArgs does not create a span if no span is running`() { + val sut = fixture.getSut(isSpanActive = false) + sut.query("query", emptyArray()) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `query with SupportSQLiteQuery creates a span if a span is running`() { + val sql = "query" + val sut = fixture.getSut() + val query = mock() + whenever(query.sql).thenReturn(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.query(query) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `query with SupportSQLiteQuery does not create a span if no span is running`() { + val sut = fixture.getSut(isSpanActive = false) + val query = mock() + whenever(query.sql).thenReturn("query") + sut.query(query) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `query with SupportSQLiteQuery and CancellationSignal creates a span if a span is running`() { + val sql = "query" + val sut = fixture.getSut() + val query = mock() + whenever(query.sql).thenReturn(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.query(query, mock()) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `query with SupportSQLiteQuery and CancellationSignal does not create a span if no span is running`() { + val sut = fixture.getSut(isSpanActive = false) + val query = mock() + whenever(query.sql).thenReturn("query") + sut.query(query, mock()) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `execSQL creates a span if a span is running`() { + val sql = "execSQL" + val sut = fixture.getSut() + assertEquals(0, fixture.sentryTracer.children.size) + sut.execSQL(sql) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `execSQL does not create a span if no span is running`() { + val sut = fixture.getSut(isSpanActive = false) + sut.execSQL("execSQL") + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `execSQL with bindArgs creates a span if a span is running`() { + val sql = "execSQL" + val sut = fixture.getSut() + assertEquals(0, fixture.sentryTracer.children.size) + sut.execSQL(sql, emptyArray()) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `execSQL with bindArgs does not create a span if no span is running`() { + val sut = fixture.getSut(isSpanActive = false) + sut.execSQL("execSQL", emptyArray()) + assertEquals(0, fixture.sentryTracer.children.size) + } + + 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) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelperTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelperTest.kt new file mode 100644 index 0000000000..47331189d9 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelperTest.kt @@ -0,0 +1,65 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.db.SupportSQLiteOpenHelper +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals + +class SentrySupportSQLiteOpenHelperTest { + + class Fixture { + val mockOpenHelper = mock() + + init { + whenever(mockOpenHelper.writableDatabase).thenReturn(mock()) + whenever(mockOpenHelper.readableDatabase).thenReturn(mock()) + } + + fun getSut(): SentrySupportSQLiteOpenHelper { + return SentrySupportSQLiteOpenHelper.create(mockOpenHelper) as SentrySupportSQLiteOpenHelper + } + } + + private val fixture = Fixture() + + @Test + fun `all calls are propagated to the delegate`() { + val openHelper = fixture.getSut() + + openHelper.writableDatabase + verify(fixture.mockOpenHelper).writableDatabase + + openHelper.readableDatabase + verify(fixture.mockOpenHelper).readableDatabase + + openHelper.databaseName + verify(fixture.mockOpenHelper).databaseName + + openHelper.close() + verify(fixture.mockOpenHelper).close() + } + + @Test + fun `writableDatabase returns a SentrySupportSQLiteDatabase`() { + val openHelper = fixture.getSut() + assertIs(openHelper.writableDatabase) + } + + @Test + fun `create returns a SentrySupportSQLiteOpenHelper wrapper`() { + val openHelper: SupportSQLiteOpenHelper = SentrySupportSQLiteOpenHelper.Companion.create(fixture.mockOpenHelper) + assertIs(openHelper) + assertNotEquals(fixture.mockOpenHelper, openHelper) + } + + @Test + fun `create returns the passed openHelper if it is a SentrySupportSQLiteOpenHelper`() { + val sentryOpenHelper = mock() + val openHelper: SupportSQLiteOpenHelper = SentrySupportSQLiteOpenHelper.Companion.create(sentryOpenHelper) + assertEquals(sentryOpenHelper, openHelper) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt new file mode 100644 index 0000000000..9078ba8b08 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt @@ -0,0 +1,174 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.db.SupportSQLiteStatement +import io.sentry.IHub +import io.sentry.ISpan +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SentrySupportSQLiteStatementTest { + + private class Fixture { + private val hub = mock() + private val spanManager = SQLiteSpanManager(hub) + val mockStatement = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut(sql: String, isSpanActive: Boolean = true): SentrySupportSQLiteStatement { + options = SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + } + whenever(hub.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + + if (isSpanActive) { + whenever(hub.span).thenReturn(sentryTracer) + } + return SentrySupportSQLiteStatement(mockStatement, spanManager, sql) + } + } + + private val fixture = Fixture() + + @Test + fun `all calls are propagated to the delegate`() { + val sql = "sql" + val statement = fixture.getSut(sql) + + inOrder(fixture.mockStatement) { + statement.execute() + verify(fixture.mockStatement).execute() + + statement.executeUpdateDelete() + verify(fixture.mockStatement).executeUpdateDelete() + + statement.executeInsert() + verify(fixture.mockStatement).executeInsert() + + statement.simpleQueryForLong() + verify(fixture.mockStatement).simpleQueryForLong() + + statement.simpleQueryForString() + verify(fixture.mockStatement).simpleQueryForString() + } + } + + @Test + fun `execute creates a span if a span is running`() { + val sql = "execute" + val sut = fixture.getSut(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.execute() + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `execute does not create a span if no span is running`() { + val sut = fixture.getSut("execute", isSpanActive = false) + sut.execute() + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `executeUpdateDelete creates a span if a span is running`() { + val sql = "executeUpdateDelete" + val sut = fixture.getSut(sql) + whenever(fixture.mockStatement.executeUpdateDelete()).thenReturn(10) + assertEquals(0, fixture.sentryTracer.children.size) + val result = sut.executeUpdateDelete() + assertEquals(10, result) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `executeUpdateDelete does not create a span if no span is running`() { + val sut = fixture.getSut("executeUpdateDelete", isSpanActive = false) + whenever(fixture.mockStatement.executeUpdateDelete()).thenReturn(10) + val result = sut.executeUpdateDelete() + assertEquals(10, result) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `executeInsert creates a span if a span is running`() { + val sql = "executeInsert" + val sut = fixture.getSut(sql) + whenever(fixture.mockStatement.executeInsert()).thenReturn(10) + assertEquals(0, fixture.sentryTracer.children.size) + val result = sut.executeInsert() + assertEquals(10, result) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `executeInsert does not create a span if no span is running`() { + val sut = fixture.getSut("executeInsert", isSpanActive = false) + whenever(fixture.mockStatement.executeInsert()).thenReturn(10) + val result = sut.executeInsert() + assertEquals(10, result) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `simpleQueryForLong creates a span if a span is running`() { + val sql = "simpleQueryForLong" + val sut = fixture.getSut(sql) + whenever(fixture.mockStatement.simpleQueryForLong()).thenReturn(10) + assertEquals(0, fixture.sentryTracer.children.size) + val result = sut.simpleQueryForLong() + assertEquals(10, result) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `simpleQueryForLong does not create a span if no span is running`() { + val sut = fixture.getSut("simpleQueryForLong", isSpanActive = false) + whenever(fixture.mockStatement.simpleQueryForLong()).thenReturn(10) + val result = sut.simpleQueryForLong() + assertEquals(10, result) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `simpleQueryForString creates a span if a span is running`() { + val sql = "simpleQueryForString" + val sut = fixture.getSut(sql) + whenever(fixture.mockStatement.simpleQueryForString()).thenReturn("10") + assertEquals(0, fixture.sentryTracer.children.size) + val result = sut.simpleQueryForString() + assertEquals("10", result) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `simpleQueryForString does not create a span if no span is running`() { + val sut = fixture.getSut("simpleQueryForString", isSpanActive = false) + whenever(fixture.mockStatement.simpleQueryForString()).thenReturn("10") + val result = sut.simpleQueryForString() + assertEquals("10", result) + assertEquals(0, fixture.sentryTracer.children.size) + } + + 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) + } +} diff --git a/sentry-android-sqlite/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sentry-android-sqlite/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/sentry-android-sqlite/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/settings.gradle.kts b/settings.gradle.kts index 4fa505e1a9..0926dfe22c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ include( "sentry-android-okhttp", "sentry-android-fragment", "sentry-android-navigation", + "sentry-android-sqlite", "sentry-compose", "sentry-compose-helper", "sentry-apollo", diff --git a/spy.log b/spy.log new file mode 100644 index 0000000000..47cafaae58 --- /dev/null +++ b/spy.log @@ -0,0 +1 @@ +1674747295574|0|statement|connection 0|url jdbc:p6spy:hsqldb:mem:testdb|CREATE TABLE person ( id INTEGER IDENTITY PRIMARY KEY, firstName VARCHAR(50) NOT NULL, lastName VARCHAR(50) NOT NULL )|CREATE TABLE person ( id INTEGER IDENTITY PRIMARY KEY, firstName VARCHAR(50) NOT NULL, lastName VARCHAR(50) NOT NULL )