diff --git a/CHANGELOG.md b/CHANGELOG.md index 141c7a20d..a8e673ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - This breaking change is only for customers using self-hosted Sentry together with a user auth token (not an org auth token) and the url and auth token are configured separately: - If so, this breaking change applies to you in order to patch a security flaw. [Please read this](https://github.com/getsentry/sentry-cli/issues/3380#issuecomment-5059013026) for further details. +### Features + +- Resolve optional Sentry SDK class availability at build time to reduce SDK initialization overhead ([#1375](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1375)) + ### Dependencies - Bump CLI from v3.6.1 to v3.6.2 ([#1373](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1373)) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index 32c7123ca..9efae003c 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt @@ -19,6 +19,7 @@ import io.sentry.android.gradle.SentryTasksProvider.getAssembleTaskProvider import io.sentry.android.gradle.SentryTasksProvider.getBundleTask import io.sentry.android.gradle.SentryTasksProvider.getMappingFileProvider import io.sentry.android.gradle.extensions.SentryPluginExtension +import io.sentry.android.gradle.instrumentation.SentrySdkOptimizationClassVisitorFactory import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory import io.sentry.android.gradle.services.SentryModulesService import io.sentry.android.gradle.snapshot.GenerateSnapshotTestsTask @@ -177,35 +178,45 @@ fun ApplicationAndroidComponentsExtension.configure( } } - if (extension.tracingInstrumentation.enabled.get()) { - /** - * We detect sentry-android SDK version using configurations.incoming.afterResolve. This is - * guaranteed to be executed BEFORE any of the build tasks/transforms are started. - * - * After detecting the sdk state, we use Gradle's shared build service to persist the state - * between builds and also during a single build, because transforms are run in parallel. - */ - val sentryModulesService = + val sdkOptimizationEnabled = extension.sdkOptimization.enabled.get() + val tracingInstrumentationEnabled = extension.tracingInstrumentation.enabled.get() + // Both visitor factories need the resolved dependency graph. + val modulesService = + if (sdkOptimizationEnabled || tracingInstrumentationEnabled) { SentryModulesService.register( - project, - extension.tracingInstrumentation.features, - extension.tracingInstrumentation.logcat.enabled, - extension.includeSourceContext, - extension.dexguardEnabled, - extension.tracingInstrumentation.appStart.enabled, - ) - /** - * We have to register SentryModulesService as a build event listener, so it will not be - * discarded after the configuration phase (where we store the collected dependencies), and - * will be passed down to the InstrumentationFactory - */ - buildEvents.onTaskCompletion(sentryModulesService) - - project.collectModules( - "${variant.name}RuntimeClasspath", - variant.name, - sentryModulesService, + project, + extension.tracingInstrumentation.features, + extension.tracingInstrumentation.logcat.enabled, + extension.includeSourceContext, + extension.dexguardEnabled, + extension.tracingInstrumentation.appStart.enabled, + ) + .also { + // Keep the service alive after configuration so instrumentation can read it. + buildEvents.onTaskCompletion(it) + } + } else { + null + } + + if (modulesService != null) { + project.collectModules("${variant.name}RuntimeClasspath", variant.name, modulesService) + } + + if (sdkOptimizationEnabled) { + variant.instrumentation.transformClassesWith( + SentrySdkOptimizationClassVisitorFactory::class.java, + InstrumentationScope.ALL, + ) { params -> + params.sentryModulesService.setDisallowChanges(checkNotNull(modulesService)) + } + variant.instrumentation.setAsmFramesComputationMode( + FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS ) + } + + if (tracingInstrumentationEnabled) { + val tracingModulesService = checkNotNull(modulesService) variant.configureInstrumentation( SpanAddingClassVisitorFactory::class.java, @@ -219,7 +230,7 @@ fun ApplicationAndroidComponentsExtension.configure( params.debug.setDisallowChanges(extension.tracingInstrumentation.debug.get()) params.logcatMinLevel.setDisallowChanges(extension.tracingInstrumentation.logcat.minLevel) - params.sentryModulesService.setDisallowChanges(sentryModulesService) + params.sentryModulesService.setDisallowChanges(tracingModulesService) params.features.setDisallowChanges(extension.tracingInstrumentation.features) params.logcatEnabled.setDisallowChanges(extension.tracingInstrumentation.logcat.enabled) params.appStartEnabled.setDisallowChanges( @@ -235,7 +246,7 @@ fun ApplicationAndroidComponentsExtension.configure( project, extension, sentryTelemetryProvider, - sentryModulesService, + tracingModulesService, variant.name, ) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SdkOptimizationExtension.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SdkOptimizationExtension.kt new file mode 100644 index 000000000..20dab9f20 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SdkOptimizationExtension.kt @@ -0,0 +1,10 @@ +package io.sentry.android.gradle.extensions + +import javax.inject.Inject +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +open class SdkOptimizationExtension @Inject constructor(objects: ObjectFactory) { + /** Enables build-time optimizations of the Sentry SDK. Defaults to true. */ + val enabled: Property = objects.property(Boolean::class.java).convention(true) +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt index 7cd7daf87..5e8fa38de 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt @@ -97,6 +97,14 @@ abstract class SentryPluginExtension @Inject constructor(objects: ObjectFactory) tracingInstrumentationAction.execute(tracingInstrumentation) } + val sdkOptimization: SdkOptimizationExtension = + objects.newInstance(SdkOptimizationExtension::class.java) + + /** Configure build-time optimizations of the Sentry SDK. Default configuration is enabled. */ + fun sdkOptimization(sdkOptimizationAction: Action) { + sdkOptimizationAction.execute(sdkOptimization) + } + val autoInstallation: AutoInstallExtension = objects.newInstance(AutoInstallExtension::class.java) /** Configure the auto installation feature. */ diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitor.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitor.kt new file mode 100644 index 000000000..ba7687476 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitor.kt @@ -0,0 +1,118 @@ +package io.sentry.android.gradle.instrumentation + +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes + +internal class LoadClassClassVisitor( + apiVersion: Int, + nextClassVisitor: ClassVisitor, + private val classAvailability: Map, +) : ClassVisitor(apiVersion, nextClassVisitor) { + private var hasAvailabilityField = false + private var hasStaticInitializer = false + + override fun visitField( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + value: Any?, + ): FieldVisitor? { + if (name == AVAILABILITY_FIELD && descriptor == MAP_DESCRIPTOR) { + hasAvailabilityField = true + } + return super.visitField(access, name, descriptor, signature, value) + } + + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor { + val visitor = super.visitMethod(access, name, descriptor, signature, exceptions) + if (name != STATIC_INITIALIZER || descriptor != VOID_METHOD_DESCRIPTOR) { + return visitor + } + + hasStaticInitializer = true + return object : MethodVisitor(api, visitor) { + override fun visitInsn(opcode: Int) { + if (opcode == Opcodes.RETURN && hasAvailabilityField) { + injectClassAvailability(this) + } + super.visitInsn(opcode) + } + } + } + + override fun visitEnd() { + if (hasAvailabilityField && !hasStaticInitializer) { + val visitor = + super.visitMethod( + Opcodes.ACC_STATIC, + STATIC_INITIALIZER, + VOID_METHOD_DESCRIPTOR, + null, + null, + ) + visitor.visitCode() + injectClassAvailability(visitor) + visitor.visitInsn(Opcodes.RETURN) + // Triggers ASM frame/max computation; arguments are ignored in compute mode. + visitor.visitMaxs(0, 0) + visitor.visitEnd() + } + super.visitEnd() + } + + private fun injectClassAvailability(visitor: MethodVisitor) { + visitor.visitTypeInsn(Opcodes.NEW, HASH_MAP_NAME) + visitor.visitInsn(Opcodes.DUP) + visitor.visitMethodInsn(Opcodes.INVOKESPECIAL, HASH_MAP_NAME, "", "()V", false) + visitor.visitFieldInsn( + Opcodes.PUTSTATIC, + LOAD_CLASS_INTERNAL_NAME, + AVAILABILITY_FIELD, + MAP_DESCRIPTOR, + ) + + classAvailability.forEach { (className, available) -> + visitor.visitFieldInsn( + Opcodes.GETSTATIC, + LOAD_CLASS_INTERNAL_NAME, + AVAILABILITY_FIELD, + MAP_DESCRIPTOR, + ) + visitor.visitLdcInsn(className) + visitor.visitInsn(if (available) Opcodes.ICONST_1 else Opcodes.ICONST_0) + visitor.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/lang/Boolean", + "valueOf", + "(Z)Ljava/lang/Boolean;", + false, + ) + visitor.visitMethodInsn( + Opcodes.INVOKEINTERFACE, + "java/util/Map", + "put", + "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", + true, + ) + visitor.visitInsn(Opcodes.POP) + } + } + + private companion object { + const val LOAD_CLASS_INTERNAL_NAME = "io/sentry/util/LoadClass" + const val AVAILABILITY_FIELD = "classAvailability" + const val MAP_DESCRIPTOR = "Ljava/util/Map;" + const val HASH_MAP_NAME = "java/util/HashMap" + const val STATIC_INITIALIZER = "" + const val VOID_METHOD_DESCRIPTOR = "()V" + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt new file mode 100644 index 000000000..10b662c8d --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt @@ -0,0 +1,75 @@ +package io.sentry.android.gradle.instrumentation + +import com.android.build.api.instrumentation.AsmClassVisitorFactory +import com.android.build.api.instrumentation.ClassContext +import com.android.build.api.instrumentation.ClassData +import com.android.build.api.instrumentation.InstrumentationParameters +import io.sentry.android.gradle.services.SentryModulesService +import io.sentry.android.gradle.util.SentryModules +import org.gradle.api.artifacts.ModuleIdentifier +import org.gradle.api.internal.artifacts.DefaultModuleIdentifier +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Internal +import org.objectweb.asm.ClassVisitor + +abstract class SentrySdkOptimizationClassVisitorFactory : + AsmClassVisitorFactory { + + interface SdkOptimizationParameters : InstrumentationParameters { + @get:Internal val sentryModulesService: Property + } + + override fun createClassVisitor( + classContext: ClassContext, + nextClassVisitor: ClassVisitor, + ): ClassVisitor { + val service = parameters.get().sentryModulesService.get() + val modules = service.sentryModules.keys + service.externalModules.keys + return LoadClassClassVisitor( + instrumentationContext.apiVersion.get(), + nextClassVisitor, + resolveClassAvailability(modules), + ) + } + + override fun isInstrumentable(classData: ClassData): Boolean = + classData.className == LOAD_CLASS_NAME + + internal companion object { + const val LOAD_CLASS_NAME = "io.sentry.util.LoadClass" + + val CLASS_MODULES: Map> = + sortedMapOf( + "androidx.compose.ui.node.Owner" to + setOf(module("androidx.compose.ui", "ui"), module("androidx.compose.ui", "ui-android")), + "androidx.core.view.ScrollingView" to setOf(module("androidx.core", "core")), + "androidx.fragment.app.FragmentManager\$FragmentLifecycleCallbacks" to + setOf(module("androidx.fragment", "fragment")), + "androidx.lifecycle.Lifecycle" to + setOf( + module("androidx.lifecycle", "lifecycle-common"), + module("androidx.lifecycle", "lifecycle-common-jvm"), + ), + "io.sentry.android.distribution.DistributionIntegration" to + setOf(SentryModules.SENTRY_ANDROID_DISTRIBUTION), + "io.sentry.android.fragment.FragmentLifecycleIntegration" to + setOf(SentryModules.SENTRY_ANDROID_FRAGMENT), + "io.sentry.android.replay.ReplayIntegration" to setOf(SentryModules.SENTRY_ANDROID_REPLAY), + "io.sentry.android.timber.SentryTimberIntegration" to + setOf(SentryModules.SENTRY_ANDROID_TIMBER), + "io.sentry.compose.gestures.ComposeGestureTargetLocator" to + setOf(SentryModules.SENTRY_ANDROID_COMPOSE, module("io.sentry", "sentry-compose")), + "io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter" to + setOf(SentryModules.SENTRY_ANDROID_COMPOSE, module("io.sentry", "sentry-compose")), + "timber.log.Timber" to setOf(module("com.jakewharton.timber", "timber")), + ) + + private fun module(group: String, name: String): ModuleIdentifier = + DefaultModuleIdentifier.newId(group, name) + } +} + +internal fun resolveClassAvailability(modules: Set): Map = + SentrySdkOptimizationClassVisitorFactory.CLASS_MODULES.mapValues { (_, owners) -> + owners.any { it in modules } + } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt index c57c774b6..dcc480c02 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt @@ -56,6 +56,8 @@ internal object SentryModules { DefaultModuleIdentifier.newId("io.sentry", "sentry-android-navigation") internal val SENTRY_ANDROID_TIMBER = DefaultModuleIdentifier.newId("io.sentry", "sentry-android-timber") + internal val SENTRY_ANDROID_REPLAY = + DefaultModuleIdentifier.newId("io.sentry", "sentry-android-replay") internal val SENTRY_ANDROID_DISTRIBUTION = DefaultModuleIdentifier.newId("io.sentry", "sentry-android-distribution") internal val SENTRY_OKHTTP = DefaultModuleIdentifier.newId("io.sentry", "sentry-okhttp") diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitorTest.kt new file mode 100644 index 000000000..723192d31 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitorTest.kt @@ -0,0 +1,141 @@ +package io.sentry.android.gradle.instrumentation + +import com.google.common.truth.Truth.assertThat +import io.sentry.android.gradle.util.SentryModules +import org.gradle.api.internal.artifacts.DefaultModuleIdentifier +import org.junit.Test +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes + +class LoadClassClassVisitorTest { + @Test + fun `resolves every known class from the module graph`() { + val modules = SentrySdkOptimizationClassVisitorFactory.CLASS_MODULES.values.flatten().toSet() + + assertThat(resolveClassAvailability(modules).values).doesNotContain(false) + } + + @Test + fun `marks every known class as unavailable when its module is absent`() { + val availability = resolveClassAvailability(emptySet()) + + assertThat(availability).hasSize(SentrySdkOptimizationClassVisitorFactory.CLASS_MODULES.size) + assertThat(availability.values).doesNotContain(true) + } + + @Test + fun `supports platform-specific module coordinates`() { + val modules = + setOf( + DefaultModuleIdentifier.newId("androidx.compose.ui", "ui-android"), + DefaultModuleIdentifier.newId("androidx.lifecycle", "lifecycle-common-jvm"), + SentryModules.SENTRY_ANDROID_REPLAY, + ) + + assertThat(resolveClassAvailability(modules)) + .containsAtLeast( + "androidx.compose.ui.node.Owner", + true, + "androidx.lifecycle.Lifecycle", + true, + "io.sentry.android.replay.ReplayIntegration", + true, + ) + } + + @Test + fun `injects availability map when static initializer is missing`() { + val availability = mapOf("present.Class" to true, "missing.Class" to false) + + val clazz = load(transformClass(hasStaticInitializer = false, availability = availability)) + + assertThat(readAvailability(clazz)).containsExactlyEntriesIn(availability) + } + + @Test + fun `injects availability map and preserves existing static initializer`() { + val availability = mapOf("present.Class" to true) + + val clazz = load(transformClass(hasStaticInitializer = true, availability = availability)) + + assertThat(clazz.getDeclaredField("marker").getInt(null)).isEqualTo(7) + assertThat(readAvailability(clazz)).containsExactlyEntriesIn(availability) + } + + @Test + fun `does not modify SDK versions without the availability field`() { + val clazz = load(transformClass(hasAvailabilityField = false)) + + assertThat(clazz.declaredFields.map { it.name }).doesNotContain("classAvailability") + } + + private fun transformClass( + hasAvailabilityField: Boolean = true, + hasStaticInitializer: Boolean = false, + availability: Map = emptyMap(), + ): ByteArray { + val original = ClassWriter(0) + original.visit( + Opcodes.V1_8, + Opcodes.ACC_PUBLIC, + LOAD_CLASS_INTERNAL_NAME, + null, + "java/lang/Object", + null, + ) + if (hasAvailabilityField) { + original + .visitField( + Opcodes.ACC_PRIVATE or Opcodes.ACC_STATIC, + "classAvailability", + "Ljava/util/Map;", + null, + null, + ) + .visitEnd() + } + if (hasStaticInitializer) { + original + .visitField(Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC, "marker", "I", null, null) + .visitEnd() + original.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null).apply { + visitCode() + visitIntInsn(Opcodes.BIPUSH, 7) + visitFieldInsn(Opcodes.PUTSTATIC, LOAD_CLASS_INTERNAL_NAME, "marker", "I") + visitInsn(Opcodes.RETURN) + visitMaxs(1, 0) + visitEnd() + } + } + original.visitEnd() + + val reader = ClassReader(original.toByteArray()) + val writer = ClassWriter(reader, ClassWriter.COMPUTE_FRAMES or ClassWriter.COMPUTE_MAXS) + reader.accept(LoadClassClassVisitor(Opcodes.ASM9, writer, availability), 0) + return writer.toByteArray() + } + + private fun load(bytes: ByteArray): Class<*> = + object : ClassLoader(javaClass.classLoader) { + fun define(): Class<*> = + defineClass( + SentrySdkOptimizationClassVisitorFactory.LOAD_CLASS_NAME, + bytes, + 0, + bytes.size, + ) + } + .define() + + @Suppress("UNCHECKED_CAST") + private fun readAvailability(clazz: Class<*>): Map = + clazz.getDeclaredField("classAvailability").run { + isAccessible = true + get(null) as Map + } + + private companion object { + const val LOAD_CLASS_INTERNAL_NAME = "io/sentry/util/LoadClass" + } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginCheckAndroidSdkTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginCheckAndroidSdkTest.kt index 4417328be..7bbc33f70 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginCheckAndroidSdkTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginCheckAndroidSdkTest.kt @@ -9,11 +9,12 @@ class SentryPluginCheckAndroidSdkTest : BaseSentryPluginTest(BuildConfig.AgpVersion, GradleVersion.current().version) { @Test - fun `when tracingInstrumentation is disabled does not check sentry-android sdk state`() { + fun `when instrumentation is disabled does not check sentry-android sdk state`() { appBuildFile.appendText( // language=Groovy """ sentry.tracingInstrumentation.enabled = false + sentry.sdkOptimization.enabled = false ${captureSdkState()} """ diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt index fb444b2bc..c837c4b78 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt @@ -450,13 +450,28 @@ class SentryPluginTest : @Test fun `skips tracing instrumentation if tracingInstrumentation is disabled`() { - applyTracingInstrumentation(false, appStart = false, logcat = false) + applyTracingInstrumentation( + tracingInstrumentation = false, + sdkOptimization = false, + appStart = false, + logcat = false, + ) val build = runner.appendArguments(":app:assembleRelease", "--dry-run").build() assertFalse(":app:transformReleaseClassesWithAsm" in build.output) } + @Test + fun `registers sdk optimization independently from tracing instrumentation`() { + applyTracingInstrumentation(false, appStart = false, logcat = false) + + val build = runner.appendArguments(":app:assembleRelease", "--info").build() + + assertTrue(":app:transformReleaseClassesWithAsm" in build.output) + assertTrue("Detected Sentry modules" in build.output) + } + @Test fun `register tracing instrumentation if tracingInstrumentation is enabled`() { applyTracingInstrumentation() @@ -1176,6 +1191,7 @@ class SentryPluginTest : private fun applyTracingInstrumentation( tracingInstrumentation: Boolean = true, + sdkOptimization: Boolean = true, features: Set = emptySet(), logcat: Boolean = false, appStart: Boolean = false, @@ -1196,6 +1212,9 @@ class SentryPluginTest : sentry { autoUploadProguardMapping = false + sdkOptimization { + enabled = $sdkOptimization + } tracingInstrumentation { forceInstrumentDependencies = $forceInstrumentDependencies enabled = $tracingInstrumentation