Skip to content
Closed
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 @@
- Detect AGP `optimization.enable` when the variant is wrapped by AGP analytics ([#1382](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1382))
- This fixes `java.lang.NoSuchMethodException: com.android.build.api.component.analytics.AnalyticsEnabledApplicationVariant_Decorated.getOptimizationCreationConfig()`

### Performance

- Resolve Sentry Android manifest metadata at build time to avoid `PackageManager` and `Bundle` parsing, reducing median SDK initialization time by 6.5% in a cold-start benchmark ([#1401](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1401))

### Dependencies

- Bump ComposablePreviewScanner from v0.9.1 to v0.9.2 ([#1381](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1381))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,20 +205,6 @@ fun ApplicationAndroidComponentsExtension.configure(
project.collectModules("${variant.name}RuntimeClasspath", variant.name, it)
}

if (runtimeOptimizationsEnabled) {
variant.instrumentation.transformClassesWith(
SentrySdkOptimizationClassVisitorFactory::class.java,
InstrumentationScope.ALL,
) { params ->
params.classAvailability.setDisallowChanges(
checkNotNull(modules).map(::resolveClassAvailability).orElse(emptyMap())
)
}
variant.instrumentation.setAsmFramesComputationMode(
FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS
)
}

if (tracingInstrumentationEnabled) {
val tracingModulesService = checkNotNull(modulesService)

Expand Down Expand Up @@ -262,6 +248,33 @@ fun ApplicationAndroidComponentsExtension.configure(
)
.toTransform(SingleArtifact.MERGED_MANIFEST)
}

if (runtimeOptimizationsEnabled) {
val runtimeModulesService = checkNotNull(modulesService)
variant.instrumentation.transformClassesWith(
SentrySdkOptimizationClassVisitorFactory::class.java,
InstrumentationScope.ALL,
) { params ->
params.classAvailability.setDisallowChanges(
checkNotNull(modules).map(::resolveClassAvailability).orElse(emptyMap())
)
params.buildTimeMetadataEnabled.setDisallowChanges(
checkNotNull(modules)
.map { runtimeModulesService.get().supportsBuildTimeMetadata() }
.orElse(false)
)
params.buildTimeMetadataCacheKey.setDisallowChanges(
params.buildTimeMetadataEnabled.map {
if (it) runtimeModulesService.get().buildTimeMetadataCacheKey else ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shared cache key reuses injected metadata

High Severity

buildTimeMetadataCacheKey now comes from SentryModulesService, a single registerIfAbsent build service shared by every project and variant. The isolation key is therefore identical across apps and flavors, so AGP can reuse ManifestMetadataReader bytecode injected for a different variant. Product flavors and debug/release often have different io.sentry.* metadata, including DSNs.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0d32407. Configure here.

}
)
Comment thread
romtsn marked this conversation as resolved.
Comment thread
romtsn marked this conversation as resolved.
params.mergedManifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
}
variant.instrumentation.setAsmFramesComputationMode(
FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS
)
}

val sizeAnalysisEnabled = extension.sizeAnalysis.enabled.get() == true
val distributionEnabled = extension.distribution.enabled.get() == true
if (sizeAnalysisEnabled || distributionEnabled) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.sentry.android.gradle

import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element

internal object ManifestMetadataParser {
fun parse(manifest: File): Map<String, Any>? =
runCatching {
val document =
manifest.inputStream().buffered().use {
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(it)
}
val application =
document.getElementsByTagName(TAG_APPLICATION).item(0) ?: return emptyMap()
val metadata = linkedMapOf<String, Any>()

for (index in 0 until application.childNodes.length) {
val element = application.childNodes.item(index) as? Element ?: continue
if (element.tagName != TAG_META_DATA) continue

val name = element.getAttribute(ATTR_NAME)
if (!name.startsWith(SENTRY_PREFIX)) continue
val value = element.getAttribute(ATTR_VALUE)
if (
element.hasAttribute(ATTR_RESOURCE) ||
!element.hasAttribute(ATTR_VALUE) ||
value.startsWith("@") ||
value.contains("${'$'}{")
) {
SentryPlugin.logger.info(
"Sentry manifest metadata was not optimized because $name could not be resolved at build time."
)
return null
}
metadata[name] = inferType(value)
}
metadata
}
.onFailure {
SentryPlugin.logger.info(
"Sentry manifest metadata could not be parsed for optimization.",
it,
)
}
.getOrNull()

internal fun inferType(value: String): Any =
when (value) {
"true" -> true
"false" -> false
else -> parseInteger(value) ?: value.toFloatOrNull() ?: value
}

private fun parseInteger(value: String): Int? =
if (value.matches(DECIMAL_INTEGER)) {
value.toIntOrNull()
} else if (value.matches(HEX_INTEGER)) {
value.removePrefix("+").let { Integer.decode(it) }
} else {
null
}

private const val TAG_APPLICATION = "application"
private const val TAG_META_DATA = "meta-data"
private const val ATTR_NAME = "android:name"
private const val ATTR_VALUE = "android:value"
private const val ATTR_RESOURCE = "android:resource"
private const val SENTRY_PREFIX = "io.sentry."
private val DECIMAL_INTEGER = Regex("[+-]?\\d+")
private val HEX_INTEGER = Regex("[+-]?0[xX][0-9a-fA-F]+")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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
import org.objectweb.asm.Opcodes

/** Injects fully resolved build-time manifest metadata into `ManifestMetadataReader`. */
internal class ManifestMetadataClassVisitor(
apiVersion: Int,
nextClassVisitor: ClassVisitor,
private val metadata: Map<String, Any>,
) : ClassVisitor(apiVersion, nextClassVisitor) {
private var hasMetadataField = false
private var hasStaticInitializer = false

override fun visitField(
access: Int,
name: String?,
descriptor: String?,
signature: String?,
value: Any?,
): FieldVisitor? {
if (name == METADATA_FIELD && descriptor == MAP_DESCRIPTOR) hasMetadataField = 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 && hasMetadataField) injectMetadata(this)
super.visitInsn(opcode)
}
}
}

override fun visitEnd() {
if (!hasMetadataField) {
SentryPlugin.logger.info(
"Sentry manifest metadata was not optimized because the current SDK version does not support this optimization."
)
}
if (hasMetadataField && !hasStaticInitializer) {
val visitor =
super.visitMethod(
Opcodes.ACC_STATIC,
STATIC_INITIALIZER,
VOID_METHOD_DESCRIPTOR,
null,
null,
)
visitor.visitCode()
injectMetadata(visitor)
visitor.visitInsn(Opcodes.RETURN)
visitor.visitMaxs(0, 0)
visitor.visitEnd()
}
super.visitEnd()
}

private fun injectMetadata(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, READER_INTERNAL_NAME, METADATA_FIELD, MAP_DESCRIPTOR)

metadata.forEach { (key, value) ->
visitor.visitFieldInsn(
Opcodes.GETSTATIC,
READER_INTERNAL_NAME,
METADATA_FIELD,
MAP_DESCRIPTOR,
)
visitor.visitLdcInsn(key)
visitor.emitValue(value)
visitor.visitMethodInsn(
Opcodes.INVOKEINTERFACE,
"java/util/Map",
"put",
"(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;",
true,
)
visitor.visitInsn(Opcodes.POP)
}
}

private fun MethodVisitor.emitValue(value: Any) {
when (value) {
is Boolean -> {
visitInsn(if (value) Opcodes.ICONST_1 else Opcodes.ICONST_0)
visitMethodInsn(
Opcodes.INVOKESTATIC,
"java/lang/Boolean",
"valueOf",
"(Z)Ljava/lang/Boolean;",
false,
)
}
is Int -> {
visitLdcInsn(value)
visitMethodInsn(
Opcodes.INVOKESTATIC,
"java/lang/Integer",
"valueOf",
"(I)Ljava/lang/Integer;",
false,
)
}
is Float -> {
visitLdcInsn(value)
visitMethodInsn(
Opcodes.INVOKESTATIC,
"java/lang/Float",
"valueOf",
"(F)Ljava/lang/Float;",
false,
)
}
is String -> visitLdcInsn(value)
}
}

private companion object {
const val READER_INTERNAL_NAME = "io/sentry/android/core/ManifestMetadataReader"
const val METADATA_FIELD = "buildTimeMetadata"
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
Expand Up @@ -4,38 +4,73 @@ 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.ManifestMetadataParser
import io.sentry.android.gradle.util.SentryModules
import org.gradle.api.artifacts.ModuleIdentifier
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.internal.artifacts.DefaultModuleIdentifier
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.objectweb.asm.ClassVisitor

abstract class SentrySdkOptimizationClassVisitorFactory :
AsmClassVisitorFactory<SentrySdkOptimizationClassVisitorFactory.SdkOptimizationParameters> {

interface SdkOptimizationParameters : InstrumentationParameters {
@get:Input val classAvailability: MapProperty<String, Boolean>

@get:Input val buildTimeMetadataEnabled: Property<Boolean>

// The merged manifest cannot be read before dependency transforms are isolated, so use a
// per-build key to prevent AGP from reusing metadata injected for another app.
@get:Input val buildTimeMetadataCacheKey: Property<String>

@get:InputFile @get:PathSensitive(PathSensitivity.NONE) val mergedManifest: RegularFileProperty
}

private fun buildTimeMetadata(): Map<String, Any>? =
if (parameters.get().buildTimeMetadataEnabled.get()) {
ManifestMetadataParser.parse(parameters.get().mergedManifest.asFile.get())
} else {
null
}

override fun createClassVisitor(
classContext: ClassContext,
nextClassVisitor: ClassVisitor,
): ClassVisitor {
return LoadClassClassVisitor(
instrumentationContext.apiVersion.get(),
nextClassVisitor,
parameters.get().classAvailability.get(),
)
}
): ClassVisitor =
when (classContext.currentClassData.className) {
LOAD_CLASS_NAME ->
LoadClassClassVisitor(
instrumentationContext.apiVersion.get(),
nextClassVisitor,
parameters.get().classAvailability.get(),
)
MANIFEST_METADATA_READER_NAME ->
ManifestMetadataClassVisitor(
instrumentationContext.apiVersion.get(),
nextClassVisitor,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The buildTimeMetadata() function is called twice without caching. A transient failure on the second call will crash the build due to an unchecked null.
Severity: MEDIUM

Suggested Fix

Cache the result of the buildTimeMetadata() call within the factory instance. The result from the first invocation in isInstrumentable should be stored and reused in createClassVisitor. This avoids re-reading the file from disk and eliminates the race condition where a transient I/O error could cause a build crash.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt#L56

Potential issue: The `buildTimeMetadata()` function, which reads and parses the merged
manifest file, is called independently in `isInstrumentable` and `createClassVisitor`
without caching the result. The Android Gradle Plugin (AGP) guarantees that if
`isInstrumentable` returns true, `createClassVisitor` will be called. However, if the
first file read succeeds but the second one fails due to a transient I/O error, the
`checkNotNull(buildTimeMetadata())` call in `createClassVisitor` will throw an
`IllegalStateException`, causing the build to crash. This creates a potential for rare,
hard-to-diagnose build failures.

Also affects:

  • plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt:69~72

checkNotNull(buildTimeMetadata()),
)
else -> nextClassVisitor
}

// 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()
when (classData.className) {
LOAD_CLASS_NAME -> parameters.get().classAvailability.get().isNotEmpty()
MANIFEST_METADATA_READER_NAME -> buildTimeMetadata() != null
else -> false
}

internal companion object {
const val LOAD_CLASS_NAME = "io.sentry.util.LoadClass"
const val MANIFEST_METADATA_READER_NAME = "io.sentry.android.core.ManifestMetadataReader"

val CLASS_MODULES: Map<String, Set<ModuleIdentifier>> =
sortedMapOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import io.sentry.android.gradle.util.SemVer
import io.sentry.android.gradle.util.SentryModules
import io.sentry.android.gradle.util.SentryVersions
import io.sentry.android.gradle.util.getBuildServiceName
import java.util.UUID
import org.gradle.api.Project
import org.gradle.api.artifacts.ModuleIdentifier
import org.gradle.api.provider.Property
Expand All @@ -22,6 +23,8 @@ import org.gradle.tooling.events.OperationCompletionListener
abstract class SentryModulesService :
BuildService<SentryModulesService.Parameters>, OperationCompletionListener {

val buildTimeMetadataCacheKey: String = UUID.randomUUID().toString()

@get:Synchronized @set:Synchronized var sentryModules: Map<ModuleIdentifier, SemVer> = emptyMap()

@get:Synchronized
Expand Down Expand Up @@ -129,6 +132,12 @@ abstract class SentryModulesService :
sentryModules.isAtLeast(SentryModules.SENTRY_ANDROID_CORE, SentryVersions.VERSION_APP_START) &&
parameters.appStartEnabled.get()

fun supportsBuildTimeMetadata(): Boolean =
sentryModules.isAtLeast(
SentryModules.SENTRY_ANDROID_CORE,
SentryVersions.VERSION_BUILD_TIME_METADATA,
)

private fun Map<ModuleIdentifier, SemVer>.isAtLeast(
module: ModuleIdentifier,
minVersion: SemVer,
Expand Down
Loading
Loading