Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good to delete this. this is false, build services are not kept across builds.

* between builds and also during a single build, because transforms are run in parallel.
*/
val sentryModulesService =
val sdkOptimizationEnabled = extension.sdkOptimization.enabled.get()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like above, i think we should rename this to runtimeOptimizations or something similar

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,
Expand All @@ -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(
Expand All @@ -235,7 +246,7 @@ fun ApplicationAndroidComponentsExtension.configure(
project,
extension,
sentryTelemetryProvider,
sentryModulesService,
tracingModulesService,
variant.name,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we name this StartupTimeOptimizations?

open class SdkOptimizationExtension @Inject constructor(objects: ObjectFactory) {
/** Enables build-time optimizations of the Sentry SDK. Defaults to true. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** Enables build-time optimizations of the Sentry SDK. Defaults to true. */
/** Enables run-time optimizations of the Sentry SDK at the cost of build time. Defaults to true. */

val enabled: Property<Boolean> = objects.property(Boolean::class.java).convention(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't we going to have different optimizations? like properties, manifest and this one? Will they all be gated behind the same flag?

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SdkOptimizationExtension>) {
sdkOptimizationAction.execute(sdkOptimization)
}

val autoInstallation: AutoInstallExtension = objects.newInstance(AutoInstallExtension::class.java)

/** Configure the auto installation feature. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a small javadoc on this one would be helpful

apiVersion: Int,
nextClassVisitor: ClassVisitor,
private val classAvailability: Map<String, Boolean>,
) : 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<out String>?,
): 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, "<init>", "()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 = "<clinit>"
const val VOID_METHOD_DESCRIPTOR = "()V"
}
}
Original file line number Diff line number Diff line change
@@ -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<SentrySdkOptimizationClassVisitorFactory.SdkOptimizationParameters> {

interface SdkOptimizationParameters : InstrumentationParameters {
@get:Internal val sentryModulesService: Property<SentryModulesService>
}

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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variants share mutable module state

Medium Severity

All variants register and write into one SentryModulesService instance, then this visitor reads that shared mutable map while injecting availability. Parallel resolution of variant classpaths can overwrite another variant's modules, so debug-only dependencies such as Timber can be recorded as present or absent for the wrong variant.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 94d96e4. Configure here.

)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale availability after dependency changes

High Severity

SdkOptimizationParameters only exposes the modules service as @Internal, so AGP's incremental ASM transform has no @Input fingerprint for the computed availability map. Availability is injected into LoadClass inside the Sentry jar; adding or removing a mapped dependency like Timber leaves that jar unchanged, so prior instrumentation can be reused and the SDK keeps trusting outdated true/false entries.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 94d96e4. Configure here.

}

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<String, Set<ModuleIdentifier>> =
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<ModuleIdentifier>): Map<String, Boolean> =
SentrySdkOptimizationClassVisitorFactory.CLASS_MODULES.mapValues { (_, owners) ->
owners.any { it in modules }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading