diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f37fd007d..785efc50588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Features +- Refactor android sqlite instrumentation (SDK side) ([#2722](https://github.com/getsentry/sentry-java/pull/2722)) - Add Screenshot and ViewHierarchy to integrations list ([#2698](https://github.com/getsentry/sentry-java/pull/2698)) - New ANR detection based on [ApplicationExitInfo API](https://developer.android.com/reference/android/app/ApplicationExitInfo) ([#2697](https://github.com/getsentry/sentry-java/pull/2697)) - This implementation completely replaces the old one (based on a watchdog) on devices running Android 11 and above: diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 40d7b109346..6d54ef9296a 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-okhttp.api b/sentry-android-sqlite/api/sentry-android-okhttp.api new file mode 100644 index 00000000000..897755948a4 --- /dev/null +++ b/sentry-android-sqlite/api/sentry-android-okhttp.api @@ -0,0 +1,21 @@ +public final class io/sentry/android/okhttp/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/okhttp/SentryOkHttpInterceptor : io/sentry/IntegrationName, okhttp3/Interceptor { + public fun ()V + public fun (Lio/sentry/IHub;)V + public fun (Lio/sentry/IHub;Lio/sentry/android/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;ZLjava/util/List;Ljava/util/List;)V + public synthetic fun (Lio/sentry/IHub;Lio/sentry/android/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;ZLjava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/android/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;)V + public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; +} + +public abstract interface class io/sentry/android/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback { + public abstract fun execute (Lio/sentry/ISpan;Lokhttp3/Request;Lokhttp3/Response;)Lio/sentry/ISpan; +} + 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 00000000000..1fe4d477d94 --- /dev/null +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -0,0 +1,90 @@ +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/SQLiteSpanManager { + public fun ()V + public fun (Lio/sentry/IHub;)V + public synthetic fun (Lio/sentry/IHub;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun performSql (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; +} + +public final class io/sentry/android/sqlite/SentrySupportSQLiteDatabase : androidx/sqlite/db/SupportSQLiteDatabase { + public fun (Landroidx/sqlite/db/SupportSQLiteDatabase;Lio/sentry/android/sqlite/SQLiteSpanManager;)V + public fun beginTransaction ()V + public fun beginTransactionNonExclusive ()V + public fun beginTransactionWithListener (Landroid/database/sqlite/SQLiteTransactionListener;)V + public fun beginTransactionWithListenerNonExclusive (Landroid/database/sqlite/SQLiteTransactionListener;)V + public fun close ()V + public fun compileStatement (Ljava/lang/String;)Landroidx/sqlite/db/SupportSQLiteStatement; + public fun delete (Ljava/lang/String;Ljava/lang/String;[Ljava/lang/Object;)I + public fun disableWriteAheadLogging ()V + public fun enableWriteAheadLogging ()Z + public fun endTransaction ()V + public fun execPerConnectionSQL (Ljava/lang/String;[Ljava/lang/Object;)V + public fun execSQL (Ljava/lang/String;)V + public fun execSQL (Ljava/lang/String;[Ljava/lang/Object;)V + public fun getAttachedDbs ()Ljava/util/List; + public fun getMaximumSize ()J + public fun getPageSize ()J + public fun getPath ()Ljava/lang/String; + public fun getVersion ()I + public fun inTransaction ()Z + public fun insert (Ljava/lang/String;ILandroid/content/ContentValues;)J + public fun isDatabaseIntegrityOk ()Z + public fun isDbLockedByCurrentThread ()Z + public fun isExecPerConnectionSQLSupported ()Z + public fun isOpen ()Z + public fun isReadOnly ()Z + public fun isWriteAheadLoggingEnabled ()Z + public fun needUpgrade (I)Z + public fun query (Landroidx/sqlite/db/SupportSQLiteQuery;)Landroid/database/Cursor; + public fun query (Landroidx/sqlite/db/SupportSQLiteQuery;Landroid/os/CancellationSignal;)Landroid/database/Cursor; + public fun query (Ljava/lang/String;)Landroid/database/Cursor; + public fun query (Ljava/lang/String;[Ljava/lang/Object;)Landroid/database/Cursor; + public fun setForeignKeyConstraintsEnabled (Z)V + public fun setLocale (Ljava/util/Locale;)V + public fun setMaxSqlCacheSize (I)V + public fun setMaximumSize (J)J + public fun setPageSize (J)V + public fun setTransactionSuccessful ()V + public fun setVersion (I)V + public fun update (Ljava/lang/String;ILandroid/content/ContentValues;Ljava/lang/String;[Ljava/lang/Object;)I + public fun yieldIfContendedSafely ()Z + public fun yieldIfContendedSafely (J)Z +} + +public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper : androidx/sqlite/db/SupportSQLiteOpenHelper { + public static final field Companion Lio/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Companion; + public fun (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)V + public fun close ()V + 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; +} + +public final class io/sentry/android/sqlite/SentrySupportSQLiteStatement : androidx/sqlite/db/SupportSQLiteStatement { + public fun (Landroidx/sqlite/db/SupportSQLiteStatement;Lio/sentry/android/sqlite/SQLiteSpanManager;Ljava/lang/String;)V + public fun bindBlob (I[B)V + public fun bindDouble (ID)V + public fun bindLong (IJ)V + public fun bindNull (I)V + public fun bindString (ILjava/lang/String;)V + public fun clearBindings ()V + public fun close ()V + public fun execute ()V + public fun executeInsert ()J + public fun executeUpdateDelete ()I + public fun simpleQueryForLong ()J + public fun simpleQueryForString ()Ljava/lang/String; +} + diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts new file mode 100644 index 00000000000..11b895f385a --- /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 00000000000..3f9ea4feb27 --- /dev/null +++ b/sentry-android-sqlite/proguard-rules.pro @@ -0,0 +1,13 @@ +##---------------Begin: proguard configuration for OkHttp ---------- + +# To ensure that stack traces is unambiguous +# https://developer.android.com/studio/build/shrink-code#decode-stack-trace +-keepattributes LineNumberTable,SourceFile + +# https://square.github.io/okhttp/features/r8_proguard/ +# If you use OkHttp as a dependency in an Android project which uses R8 as a default compiler you +# don’t have to do anything. The specific rules are already bundled into the JAR which can +# be interpreted by R8 automatically. +# https://raw.githubusercontent.com/square/okhttp/master/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro + +##---------------End: proguard configuration for OkHttp ---------- 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 00000000000..099adea77c7 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -0,0 +1,43 @@ +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 + +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 + */ + @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: SQLException) { + span?.status = SpanStatus.INTERNAL_ERROR + span?.throwable = e + throw e + } catch (e: UnsupportedOperationException) { + 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 00000000000..a34519257e9 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt @@ -0,0 +1,80 @@ +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 + +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) + } + + /** Execute the given SQL statement on all connections to this database. */ + @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 00000000000..2c66cfb3e18 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt @@ -0,0 +1,29 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper + +class SentrySupportSQLiteOpenHelper( + private val delegate: SupportSQLiteOpenHelper +) : SupportSQLiteOpenHelper by delegate { + + private val sqLiteSpanManager = SQLiteSpanManager() + + private val sentryDatabase: SupportSQLiteDatabase by lazy { + SentrySupportSQLiteDatabase(delegate.writableDatabase, sqLiteSpanManager) + } + + override val writableDatabase: SupportSQLiteDatabase + get() = sentryDatabase + + companion object { + + 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 00000000000..7fdf2159768 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt @@ -0,0 +1,80 @@ +package io.sentry.android.sqlite + +import androidx.sqlite.db.SupportSQLiteStatement + +class SentrySupportSQLiteStatement( + private val delegate: SupportSQLiteStatement, + private val sqLiteSpanManager: SQLiteSpanManager, + private val sql: String +) : SupportSQLiteStatement by delegate { + + /** + * Execute this SQL statement, if it is not a SELECT / INSERT / DELETE / UPDATE, for example + * CREATE / DROP table, view, trigger, index etc. + * + * @throws [android.database.SQLException] If the SQL string is invalid for + * some reason + */ + override fun execute() { + return sqLiteSpanManager.performSql(sql) { + delegate.execute() + } + } + + /** + * Execute this SQL statement, if the the number of rows affected by execution of this SQL + * statement is of any importance to the caller - for example, UPDATE / DELETE SQL statements. + * + * @return the number of rows affected by this SQL statement execution. + * @throws [android.database.SQLException] If the SQL string is invalid for + * some reason + */ + override fun executeUpdateDelete(): Int { + return sqLiteSpanManager.performSql(sql) { + delegate.executeUpdateDelete() + } + } + + /** + * Execute this SQL statement and return the ID of the row inserted due to this call. + * The SQL statement should be an INSERT for this to be a useful call. + * + * @return the row ID of the last row inserted, if this insert is successful. -1 otherwise. + * + * @throws [android.database.SQLException] If the SQL string is invalid for + * some reason + */ + override fun executeInsert(): Long { + return sqLiteSpanManager.performSql(sql) { + delegate.executeInsert() + } + } + + /** + * Execute a statement that returns a 1 by 1 table with a numeric value. + * For example, SELECT COUNT(*) FROM table; + * + * @return The result of the query. + * + * @throws [android.database.sqlite.SQLiteDoneException] if the query returns zero rows + */ + override fun simpleQueryForLong(): Long { + return sqLiteSpanManager.performSql(sql) { + delegate.simpleQueryForLong() + } + } + + /** + * Execute a statement that returns a 1 by 1 table with a text value. + * For example, SELECT COUNT(*) FROM table; + * + * @return The result of the query. + * + * @throws [android.database.sqlite.SQLiteDoneException] if the query returns zero rows + */ + 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 00000000000..3607212a730 --- /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 { + + 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 00000000000..b66f93214a7 --- /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 { + + 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 00000000000..04554f73561 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelperTest.kt @@ -0,0 +1,64 @@ +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()) + } + + fun getSut(): SentrySupportSQLiteOpenHelper { + return SentrySupportSQLiteOpenHelper(mockOpenHelper) + } + } + + 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 00000000000..30169ad91e8 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt @@ -0,0 +1,158 @@ +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 { + + 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) + assertEquals(0, fixture.sentryTracer.children.size) + sut.executeUpdateDelete() + 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) + sut.executeUpdateDelete() + 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) + assertEquals(0, fixture.sentryTracer.children.size) + sut.executeInsert() + 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) + sut.executeInsert() + 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) + assertEquals(0, fixture.sentryTracer.children.size) + sut.simpleQueryForLong() + 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) + sut.simpleQueryForLong() + 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) + assertEquals(0, fixture.sentryTracer.children.size) + sut.simpleQueryForString() + 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) + sut.simpleQueryForString() + 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 00000000000..1f0955d450f --- /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 4fa505e1a90..0926dfe22c1 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 00000000000..47cafaae586 --- /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 )