From ad685a191a5e6e055b4488d0e866d77497c917ba Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Jul 2026 14:17:44 +0200 Subject: [PATCH 01/13] perf(instrumentation): Resolve SDK class availability at build time Add an independently configurable ASM visitor that derives known optional classes from resolved modules and injects their availability into LoadClass. Avoid repeated reflection during SDK startup. Refs JAVA-654 Co-Authored-By: Codex --- .../android/gradle/AndroidComponentsConfig.kt | 69 +++++---- .../extensions/SdkOptimizationExtension.kt | 10 ++ .../extensions/SentryPluginExtension.kt | 8 + .../instrumentation/LoadClassClassVisitor.kt | 117 +++++++++++++++ ...entrySdkOptimizationClassVisitorFactory.kt | 75 ++++++++++ .../io/sentry/android/gradle/util/Versions.kt | 2 + .../LoadClassClassVisitorTest.kt | 141 ++++++++++++++++++ .../SentryPluginCheckAndroidSdkTest.kt | 3 +- .../gradle/integration/SentryPluginTest.kt | 21 ++- 9 files changed, 415 insertions(+), 31 deletions(-) create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SdkOptimizationExtension.kt create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitor.kt create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt create mode 100644 plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitorTest.kt 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 a97384a42..6a9c80a0f 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..ea1945f29 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitor.kt @@ -0,0 +1,117 @@ +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) + visitor.visitMaxs(3, 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 c9919b17e..19f41a239 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 @@ -60,6 +60,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 a5198b6c4..3d73c8bdb 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 @@ -490,13 +490,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() @@ -1216,6 +1231,7 @@ class SentryPluginTest : private fun applyTracingInstrumentation( tracingInstrumentation: Boolean = true, + sdkOptimization: Boolean = true, features: Set = emptySet(), logcat: Boolean = false, appStart: Boolean = false, @@ -1236,6 +1252,9 @@ class SentryPluginTest : sentry { autoUploadProguardMapping = false + sdkOptimization { + enabled = $sdkOptimization + } tracingInstrumentation { forceInstrumentDependencies = $forceInstrumentDependencies enabled = $tracingInstrumentation From 632c774950644bb59da7159d078620c52835c9cf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Jul 2026 14:28:20 +0200 Subject: [PATCH 02/13] docs(changelog): Add SDK optimization entry Document the build-time SDK class availability optimization introduced in #1375. Refs JAVA-654 Co-Authored-By: Codex --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fea1f153..3afb870d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,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)) From 13cc92a413eac27bf11670ad1f07816650fc03bd Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Jul 2026 16:33:03 +0200 Subject: [PATCH 03/13] ref(instrumentation): Defer max computation to ASM Use zero placeholders and document that visitMaxs triggers frame and max computation. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- .../android/gradle/instrumentation/LoadClassClassVisitor.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index ea1945f29..ba7687476 100644 --- 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 @@ -62,7 +62,8 @@ internal class LoadClassClassVisitor( visitor.visitCode() injectClassAvailability(visitor) visitor.visitInsn(Opcodes.RETURN) - visitor.visitMaxs(3, 0) + // Triggers ASM frame/max computation; arguments are ignored in compute mode. + visitor.visitMaxs(0, 0) visitor.visitEnd() } super.visitEnd() From 960d72da5d374e1d8d8fa4e2c0ab955d9f429601 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 6 Aug 2026 16:11:34 +0200 Subject: [PATCH 04/13] docs(instrumentation): Explain class availability injection Document the generated runtime state and reflection fallback. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- .../instrumentation/LoadClassClassVisitor.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index ba7687476..a71848754 100644 --- 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 @@ -5,6 +5,24 @@ import org.objectweb.asm.FieldVisitor import org.objectweb.asm.MethodVisitor import org.objectweb.asm.Opcodes +/** + * Injects build-time knowledge of optional classes into `LoadClass`. + * + * `classAvailability` maps class names probed during SDK initialization to whether their owning + * dependency is present. Before instrumentation the field is uninitialized: + * ```java + * static Map classAvailability; + * ``` + * + * After instrumentation the generated static initializer is equivalent to: + * ```java + * classAvailability = new HashMap<>(); + * classAvailability.put("timber.log.Timber", true); + * ``` + * + * Known entries avoid reflection; omitted class names still fall back to it. SDK versions without + * the field are left unchanged. + */ internal class LoadClassClassVisitor( apiVersion: Int, nextClassVisitor: ClassVisitor, From cfac2fa38667fbf37661231f964c8f4adc6a287c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 00:21:52 +0200 Subject: [PATCH 05/13] fix(instrumentation): Track class availability per variant Pass resolved class availability as an instrumentation input so dependency changes invalidate cached output and variants cannot read shared mutable state. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- .../android/gradle/AndroidComponentsConfig.kt | 12 ++++++++---- .../SentrySdkOptimizationClassVisitorFactory.kt | 11 ++++------- .../android/gradle/util/SentryModulesCollector.kt | 10 ++++++++-- .../gradle/util/SentryModulesCollectorTest.kt | 13 ++++++++----- 4 files changed, 28 insertions(+), 18 deletions(-) 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 6a9c80a0f..cdce044b9 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 @@ -21,6 +21,7 @@ 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.instrumentation.resolveClassAvailability import io.sentry.android.gradle.services.SentryModulesService import io.sentry.android.gradle.snapshot.GenerateSnapshotTestsTask import io.sentry.android.gradle.sourcecontext.OutputPaths @@ -199,16 +200,19 @@ fun ApplicationAndroidComponentsExtension.configure( null } - if (modulesService != null) { - project.collectModules("${variant.name}RuntimeClasspath", variant.name, modulesService) - } + val modules = + modulesService?.let { + project.collectModules("${variant.name}RuntimeClasspath", variant.name, it) + } if (sdkOptimizationEnabled) { variant.instrumentation.transformClassesWith( SentrySdkOptimizationClassVisitorFactory::class.java, InstrumentationScope.ALL, ) { params -> - params.sentryModulesService.setDisallowChanges(checkNotNull(modulesService)) + params.classAvailability.setDisallowChanges( + checkNotNull(modules).map(::resolveClassAvailability) + ) } variant.instrumentation.setAsmFramesComputationMode( FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS 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 index 10b662c8d..04d41a894 100644 --- 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 @@ -4,31 +4,28 @@ 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.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input import org.objectweb.asm.ClassVisitor abstract class SentrySdkOptimizationClassVisitorFactory : AsmClassVisitorFactory { interface SdkOptimizationParameters : InstrumentationParameters { - @get:Internal val sentryModulesService: Property + @get:Input val classAvailability: MapProperty } 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), + parameters.get().classAvailability.get(), ) } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt index ba8424625..43d33fb1d 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt @@ -12,7 +12,7 @@ fun Project.collectModules( configurationName: String, variantName: String, sentryModulesService: Provider, -) { +): Provider> { val configProvider = try { configurations.named(configurationName) @@ -20,7 +20,7 @@ fun Project.collectModules( logger.warn { "Unable to find configuration $configurationName for variant $variantName." } sentryModulesService.get().sentryModules = emptyMap() sentryModulesService.get().externalModules = emptyMap() - return + return provider { emptySet() } } configProvider.configure { configuration -> @@ -40,6 +40,12 @@ fun Project.collectModules( sentryModulesService.get().externalModules = externalModules } } + + return configProvider.map { configuration -> + configuration.incoming.resolutionResult.allComponents + .mapNotNull { it.moduleVersion?.module } + .toSet() + } } private fun Set.versionMap( diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt index 71481c042..fcddae0a9 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.gradle.util +import com.google.common.truth.Truth.assertThat import io.sentry.android.gradle.extensions.InstrumentationFeature import io.sentry.android.gradle.instrumentation.fakes.CapturingTestLogger import io.sentry.android.gradle.services.SentryModulesService @@ -189,13 +190,15 @@ class SentryModulesCollectorTest { } val project = fixture.getSut(testProjectDir.root, dependencies = setOf(sqliteDep)) - project.collectModules( - fixture.configurationName, - fixture.variantName, - fixture.sentryModulesServiceProvider, - ) + val modules = + project.collectModules( + fixture.configurationName, + fixture.variantName, + fixture.sentryModulesServiceProvider, + ) assertTrue { fixture.getExternalModules()[moduleIdentifier]!! == SemVer.parse(version) } + assertThat(modules.get()).containsExactly(moduleIdentifier) } @Test From 7defbeb840906100ef08b77e1e33e27904f2fd4c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 10:34:36 +0200 Subject: [PATCH 06/13] ref(instrumentation): Rename runtime optimization DSL Name the configuration after its runtime effect and document the associated build-time cost. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- .../sentry/android/gradle/AndroidComponentsConfig.kt | 6 +++--- ...onExtension.kt => RuntimeOptimizationsExtension.kt} | 6 ++++-- .../android/gradle/extensions/SentryPluginExtension.kt | 10 +++++----- .../integration/SentryPluginCheckAndroidSdkTest.kt | 2 +- .../android/gradle/integration/SentryPluginTest.kt | 10 +++++----- 5 files changed, 18 insertions(+), 16 deletions(-) rename plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/{SdkOptimizationExtension.kt => RuntimeOptimizationsExtension.kt} (55%) 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 cdce044b9..397360128 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 @@ -179,11 +179,11 @@ fun ApplicationAndroidComponentsExtension.configure( } } - val sdkOptimizationEnabled = extension.sdkOptimization.enabled.get() + val runtimeOptimizationsEnabled = extension.runtimeOptimizations.enabled.get() val tracingInstrumentationEnabled = extension.tracingInstrumentation.enabled.get() // Both visitor factories need the resolved dependency graph. val modulesService = - if (sdkOptimizationEnabled || tracingInstrumentationEnabled) { + if (runtimeOptimizationsEnabled || tracingInstrumentationEnabled) { SentryModulesService.register( project, extension.tracingInstrumentation.features, @@ -205,7 +205,7 @@ fun ApplicationAndroidComponentsExtension.configure( project.collectModules("${variant.name}RuntimeClasspath", variant.name, it) } - if (sdkOptimizationEnabled) { + if (runtimeOptimizationsEnabled) { variant.instrumentation.transformClassesWith( SentrySdkOptimizationClassVisitorFactory::class.java, InstrumentationScope.ALL, 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/RuntimeOptimizationsExtension.kt similarity index 55% rename from plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SdkOptimizationExtension.kt rename to plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/RuntimeOptimizationsExtension.kt index 20dab9f20..e22fef4f3 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SdkOptimizationExtension.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/RuntimeOptimizationsExtension.kt @@ -4,7 +4,9 @@ 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. */ +open class RuntimeOptimizationsExtension @Inject constructor(objects: ObjectFactory) { + /** + * Enables runtime optimizations of the Sentry SDK at the cost of build time. 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 5e8fa38de..813882981 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,12 +97,12 @@ abstract class SentryPluginExtension @Inject constructor(objects: ObjectFactory) tracingInstrumentationAction.execute(tracingInstrumentation) } - val sdkOptimization: SdkOptimizationExtension = - objects.newInstance(SdkOptimizationExtension::class.java) + val runtimeOptimizations: RuntimeOptimizationsExtension = + objects.newInstance(RuntimeOptimizationsExtension::class.java) - /** Configure build-time optimizations of the Sentry SDK. Default configuration is enabled. */ - fun sdkOptimization(sdkOptimizationAction: Action) { - sdkOptimizationAction.execute(sdkOptimization) + /** Configure runtime optimizations of the Sentry SDK. Default configuration is enabled. */ + fun runtimeOptimizations(runtimeOptimizationsAction: Action) { + runtimeOptimizationsAction.execute(runtimeOptimizations) } val autoInstallation: AutoInstallExtension = objects.newInstance(AutoInstallExtension::class.java) 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 7bbc33f70..98e1b8e1d 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 @@ -14,7 +14,7 @@ class SentryPluginCheckAndroidSdkTest : // language=Groovy """ sentry.tracingInstrumentation.enabled = false - sentry.sdkOptimization.enabled = false + sentry.runtimeOptimizations.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 3d73c8bdb..1bc29f880 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 @@ -492,7 +492,7 @@ class SentryPluginTest : fun `skips tracing instrumentation if tracingInstrumentation is disabled`() { applyTracingInstrumentation( tracingInstrumentation = false, - sdkOptimization = false, + runtimeOptimizations = false, appStart = false, logcat = false, ) @@ -503,7 +503,7 @@ class SentryPluginTest : } @Test - fun `registers sdk optimization independently from tracing instrumentation`() { + fun `registers runtime optimizations independently from tracing instrumentation`() { applyTracingInstrumentation(false, appStart = false, logcat = false) val build = runner.appendArguments(":app:assembleRelease", "--info").build() @@ -1231,7 +1231,7 @@ class SentryPluginTest : private fun applyTracingInstrumentation( tracingInstrumentation: Boolean = true, - sdkOptimization: Boolean = true, + runtimeOptimizations: Boolean = true, features: Set = emptySet(), logcat: Boolean = false, appStart: Boolean = false, @@ -1252,8 +1252,8 @@ class SentryPluginTest : sentry { autoUploadProguardMapping = false - sdkOptimization { - enabled = $sdkOptimization + runtimeOptimizations { + enabled = $runtimeOptimizations } tracingInstrumentation { forceInstrumentDependencies = $forceInstrumentDependencies From fa2580f70a5c4041eeb7eabe89026a3e2615a1d6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 11:40:13 +0200 Subject: [PATCH 07/13] chore(instrumentation): Log skipped reflection optimization Explain at info level when the current Sentry SDK cannot use the runtime reflection-check optimization. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- .../android/gradle/instrumentation/LoadClassClassVisitor.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 index a71848754..2103416e0 100644 --- 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 @@ -1,5 +1,6 @@ package io.sentry.android.gradle.instrumentation +import io.sentry.android.gradle.SentryPlugin import org.objectweb.asm.ClassVisitor import org.objectweb.asm.FieldVisitor import org.objectweb.asm.MethodVisitor @@ -68,6 +69,11 @@ internal class LoadClassClassVisitor( } override fun visitEnd() { + if (!hasAvailabilityField) { + SentryPlugin.logger.info( + "Sentry SDK runtime reflection checks were not optimized because the current SDK version does not support this optimization." + ) + } if (hasAvailabilityField && !hasStaticInitializer) { val visitor = super.visitMethod( From 59b25506e02c493a51d4d854e5f535b1726efcc8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 12:55:41 +0200 Subject: [PATCH 08/13] docs(changelog): Expand runtime optimization entry Describe the eliminated reflection checks, relative benchmark results, uncertainty, and the opt-out setting. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afb870d1..2d1293c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,12 @@ - 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 +### Performance -- 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)) +- Eliminate the reflection cost of known optional Sentry SDK class-availability checks by resolving them at build time ([#1375](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1375)) + - On a Pixel 2 XL, constructing and querying the availability map was about 160x faster than an absent-heavy batch of 11 reflection checks. + - In an absent-heavy startup benchmark, SDK initialization was 1.08% faster and full startup was 0.88% faster. These differences were within benchmark variance. + - This optimization is enabled by default. If it causes problems, disable it with `sentry.runtimeOptimizations.enabled = false`. ### Dependencies From c65f6267a5eb882325cf62d425bbbbb787813877 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 13:05:25 +0200 Subject: [PATCH 09/13] docs(changelog): Move optimization entry to Unreleased Keep the pending runtime optimization change out of the released 6.17.0 section after rebasing. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d1293c39..70b608236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ - Support ProGuard mapping tasks when R8 is enabled with the AGP app `optimization.enable` DSL ([#1376](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1376)) +### Performance + +- Eliminate the reflection cost of known optional Sentry SDK class-availability checks by resolving them at build time ([#1375](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1375)) + - On a Pixel 2 XL, constructing and querying the availability map was about 160x faster than an absent-heavy batch of 11 reflection checks. + - In an absent-heavy startup benchmark, SDK initialization was 1.08% faster and full startup was 0.88% faster. These differences were within benchmark variance. + - This optimization is enabled by default. If it causes problems, disable it with `sentry.runtimeOptimizations.enabled = false`. + ### Dependencies - Bump Android SDK from v8.51.0 to v8.52.0 ([#1378](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1378)) @@ -19,13 +26,6 @@ - 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. -### Performance - -- Eliminate the reflection cost of known optional Sentry SDK class-availability checks by resolving them at build time ([#1375](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1375)) - - On a Pixel 2 XL, constructing and querying the availability map was about 160x faster than an absent-heavy batch of 11 reflection checks. - - In an absent-heavy startup benchmark, SDK initialization was 1.08% faster and full startup was 0.88% faster. These differences were within benchmark variance. - - This optimization is enabled by default. If it causes problems, disable it with `sentry.runtimeOptimizations.enabled = false`. - ### Dependencies - Bump CLI from v3.6.1 to v3.6.2 ([#1373](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1373)) From 5a9e232d73eb4993303dc2a17db8fd61290fb37c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 13:11:39 +0200 Subject: [PATCH 10/13] docs(changelog): Simplify optimization benchmark details Keep the headline SDK initialization result and opt-out guidance while leaving detailed measurements in the PR. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b608236..4b990b193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,7 @@ ### Performance -- Eliminate the reflection cost of known optional Sentry SDK class-availability checks by resolving them at build time ([#1375](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1375)) - - On a Pixel 2 XL, constructing and querying the availability map was about 160x faster than an absent-heavy batch of 11 reflection checks. - - In an absent-heavy startup benchmark, SDK initialization was 1.08% faster and full startup was 0.88% faster. These differences were within benchmark variance. +- Eliminate reflection for known optional Sentry SDK class-availability checks, reducing SDK initialization time by about 1% in an absent-heavy startup benchmark ([#1375](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1375)) - This optimization is enabled by default. If it causes problems, disable it with `sentry.runtimeOptimizations.enabled = false`. ### Dependencies From 439374f3c106a8c6dbba5670e195079e16bb082e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 13:25:49 +0200 Subject: [PATCH 11/13] fix(instrumentation): Preserve fallback without classpath Skip SDK runtime optimization instrumentation when a variant runtime classpath cannot be found. Preserve reflection as the safe fallback instead of marking every optional class absent. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- .../android/gradle/AndroidComponentsConfig.kt | 6 ++---- .../gradle/util/SentryModulesCollector.kt | 4 ++-- .../gradle/util/SentryModulesCollectorTest.kt | 16 +++++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) 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 397360128..6d5a9397b 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 @@ -205,14 +205,12 @@ fun ApplicationAndroidComponentsExtension.configure( project.collectModules("${variant.name}RuntimeClasspath", variant.name, it) } - if (runtimeOptimizationsEnabled) { + if (runtimeOptimizationsEnabled && modules != null) { variant.instrumentation.transformClassesWith( SentrySdkOptimizationClassVisitorFactory::class.java, InstrumentationScope.ALL, ) { params -> - params.classAvailability.setDisallowChanges( - checkNotNull(modules).map(::resolveClassAvailability) - ) + params.classAvailability.setDisallowChanges(modules.map(::resolveClassAvailability)) } variant.instrumentation.setAsmFramesComputationMode( FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt index 43d33fb1d..865f0f6c6 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt @@ -12,7 +12,7 @@ fun Project.collectModules( configurationName: String, variantName: String, sentryModulesService: Provider, -): Provider> { +): Provider>? { val configProvider = try { configurations.named(configurationName) @@ -20,7 +20,7 @@ fun Project.collectModules( logger.warn { "Unable to find configuration $configurationName for variant $variantName." } sentryModulesService.get().sentryModules = emptyMap() sentryModulesService.get().externalModules = emptyMap() - return provider { emptySet() } + return null } configProvider.configure { configuration -> diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt index fcddae0a9..117551677 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt @@ -85,14 +85,16 @@ class SentryModulesCollectorTest { private val fixture = Fixture() @Test - fun `configuration cannot be found - logs a warning and the modules list is empty`() { + fun `configuration cannot be found - logs a warning and does not provide modules`() { val project = fixture.getSut(testProjectDir.root) - project.collectModules( - "releaseRuntimeClasspath", - "release", - fixture.sentryModulesServiceProvider, - ) + val modules = + project.collectModules( + "releaseRuntimeClasspath", + "release", + fixture.sentryModulesServiceProvider, + ) assertTrue { fixture.getSentryModules().isEmpty() } + assertThat(modules).isNull() assertTrue { fixture.logger.capturedMessage == "[sentry] Unable to find configuration releaseRuntimeClasspath for variant release." @@ -198,7 +200,7 @@ class SentryModulesCollectorTest { ) assertTrue { fixture.getExternalModules()[moduleIdentifier]!! == SemVer.parse(version) } - assertThat(modules.get()).containsExactly(moduleIdentifier) + assertThat(checkNotNull(modules).get()).containsExactly(moduleIdentifier) } @Test From b72ad39188f88ab17881c3130c16069f28a95ac8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 13:33:46 +0200 Subject: [PATCH 12/13] fix(instrumentation): Scope classpath fallback to LoadClass Keep the SDK optimization factory registered when class availability is unknown. Skip only LoadClass instrumentation so future optimizations remain independent. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- .../io/sentry/android/gradle/AndroidComponentsConfig.kt | 6 ++++-- .../SentrySdkOptimizationClassVisitorFactory.kt | 2 +- .../io/sentry/android/gradle/util/SentryModulesCollector.kt | 4 ++-- .../android/gradle/util/SentryModulesCollectorTest.kt | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) 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 6d5a9397b..b4af24b0c 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 @@ -205,12 +205,14 @@ fun ApplicationAndroidComponentsExtension.configure( project.collectModules("${variant.name}RuntimeClasspath", variant.name, it) } - if (runtimeOptimizationsEnabled && modules != null) { + if (runtimeOptimizationsEnabled) { variant.instrumentation.transformClassesWith( SentrySdkOptimizationClassVisitorFactory::class.java, InstrumentationScope.ALL, ) { params -> - params.classAvailability.setDisallowChanges(modules.map(::resolveClassAvailability)) + params.classAvailability.setDisallowChanges( + checkNotNull(modules).map(::resolveClassAvailability).orElse(emptyMap()) + ) } variant.instrumentation.setAsmFramesComputationMode( FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS 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 index 04d41a894..e207d29c4 100644 --- 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 @@ -30,7 +30,7 @@ abstract class SentrySdkOptimizationClassVisitorFactory : } override fun isInstrumentable(classData: ClassData): Boolean = - classData.className == LOAD_CLASS_NAME + classData.className == LOAD_CLASS_NAME && parameters.get().classAvailability.get().isNotEmpty() internal companion object { const val LOAD_CLASS_NAME = "io.sentry.util.LoadClass" diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt index 865f0f6c6..a0d161c7e 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt @@ -12,7 +12,7 @@ fun Project.collectModules( configurationName: String, variantName: String, sentryModulesService: Provider, -): Provider>? { +): Provider> { val configProvider = try { configurations.named(configurationName) @@ -20,7 +20,7 @@ fun Project.collectModules( logger.warn { "Unable to find configuration $configurationName for variant $variantName." } sentryModulesService.get().sentryModules = emptyMap() sentryModulesService.get().externalModules = emptyMap() - return null + return provider> { null } } configProvider.configure { configuration -> diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt index 117551677..2ace513b5 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt @@ -94,7 +94,7 @@ class SentryModulesCollectorTest { fixture.sentryModulesServiceProvider, ) assertTrue { fixture.getSentryModules().isEmpty() } - assertThat(modules).isNull() + assertThat(modules.isPresent).isFalse() assertTrue { fixture.logger.capturedMessage == "[sentry] Unable to find configuration releaseRuntimeClasspath for variant release." @@ -200,7 +200,7 @@ class SentryModulesCollectorTest { ) assertTrue { fixture.getExternalModules()[moduleIdentifier]!! == SemVer.parse(version) } - assertThat(checkNotNull(modules).get()).containsExactly(moduleIdentifier) + assertThat(modules.get()).containsExactly(moduleIdentifier) } @Test From c36cc3d2f036116a2a2959be5dc91979ef1c717e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Aug 2026 13:44:34 +0200 Subject: [PATCH 13/13] docs(instrumentation): Explain LoadClass fallback Document why unknown class availability skips the LoadClass transformation. Refs LINEAR-JAVA-654 Co-Authored-By: Codex --- .../instrumentation/SentrySdkOptimizationClassVisitorFactory.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index e207d29c4..5f5cb497c 100644 --- 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 @@ -29,6 +29,8 @@ abstract class SentrySdkOptimizationClassVisitorFactory : ) } + // Empty availability means the runtime classpath is unknown. Skip this transformation so + // LoadClass falls back to reflection. override fun isInstrumentable(classData: ClassData): Boolean = classData.className == LOAD_CLASS_NAME && parameters.get().classAvailability.get().isNotEmpty()