From bdd4df21fac7be67d6db66ca821b815b70d0f00e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 14 Aug 2026 01:05:31 +0200 Subject: [PATCH 01/10] perf(instrumentation): Generate manifest metadata at build time Read resolved Sentry metadata from the final merged manifest in the generated-source task. Keep the SDK transform parameterless by emitting a stable call to the app-owned generated class, with PackageManager fallback for unresolved resources. Co-Authored-By: OpenAI Codex --- .../android/gradle/AndroidComponentsConfig.kt | 3 + .../android/gradle/ManifestMetadataParser.kt | 65 ++++++++++++ .../ManifestMetadataClassVisitor.kt | 89 +++++++++++++++++ ...entrySdkOptimizationClassVisitorFactory.kt | 14 ++- .../GenerateSentryBuildTimeOptionsTask.kt | 52 ++++++++++ .../core/SentryGeneratedBuildTimeOptions.kt | 3 + .../gradle/ManifestMetadataParserTest.kt | 79 +++++++++++++++ .../ManifestMetadataClassVisitorTest.kt | 98 +++++++++++++++++++ .../gradle/integration/SentryPluginTest.kt | 88 +++++++++++++++++ .../GenerateSentryBuildTimeOptionsTaskTest.kt | 19 +++- 10 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt create mode 100644 plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt create mode 100644 plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.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 b2f52e7ac..0baef3d33 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 @@ -212,6 +212,9 @@ fun ApplicationAndroidComponentsExtension.configure( ) val javaSources = variant.sources.java if (buildTimeOptionsTask != null && javaSources != null) { + buildTimeOptionsTask.configure { + it.mergedManifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)) + } javaSources.addGeneratedSourceDirectory( buildTimeOptionsTask, GenerateSentryBuildTimeOptionsTask::output, diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt new file mode 100644 index 000000000..c2c92e99b --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt @@ -0,0 +1,65 @@ +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? = + runCatching { + val document = + manifest.inputStream().buffered().use { + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(it) + } + val application = document.getElementsByTagName("application").item(0) ?: return emptyMap() + val metadata = linkedMapOf() + + for (index in 0 until application.childNodes.length) { + val element = application.childNodes.item(index) as? Element ?: continue + if (element.tagName != "meta-data") continue + + val name = element.getAttribute("android:name") + if (!name.startsWith("io.sentry.")) continue + val value = element.getAttribute("android:value") + if ( + element.hasAttribute("android:resource") || + !element.hasAttribute("android: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()?.takeIf { it.isFinite() } ?: 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 val DECIMAL_INTEGER = Regex("[+-]?\\d+") + private val HEX_INTEGER = Regex("[+-]?0[xX][0-9a-fA-F]+") +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt new file mode 100644 index 000000000..1b01564c5 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt @@ -0,0 +1,89 @@ +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 a call to app-owned build-time manifest metadata into `ManifestMetadataReader`. */ +internal class ManifestMetadataClassVisitor(apiVersion: Int, nextClassVisitor: ClassVisitor) : + 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?, + ): 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.visitMethodInsn( + Opcodes.INVOKESTATIC, + GENERATED_OPTIONS_INTERNAL_NAME, + "getManifestMetadata", + "()Ljava/util/Map;", + false, + ) + visitor.visitFieldInsn(Opcodes.PUTSTATIC, READER_INTERNAL_NAME, METADATA_FIELD, MAP_DESCRIPTOR) + } + + private companion object { + const val READER_INTERNAL_NAME = "io/sentry/android/core/ManifestMetadataReader" + const val GENERATED_OPTIONS_INTERNAL_NAME = + "io/sentry/android/core/SentryGeneratedBuildTimeOptions" + const val METADATA_FIELD = "buildTimeMetadata" + const val MAP_DESCRIPTOR = "Ljava/util/Map;" + 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 index cc6ed942f..c521f3b02 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 @@ -15,15 +15,21 @@ abstract class SentrySdkOptimizationClassVisitorFactory : override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor, - ): ClassVisitor { - return LoadClassClassVisitor(instrumentationContext.apiVersion.get(), nextClassVisitor) - } + ): ClassVisitor = + when (classContext.currentClassData.className) { + LOAD_CLASS_NAME -> + LoadClassClassVisitor(instrumentationContext.apiVersion.get(), nextClassVisitor) + MANIFEST_METADATA_READER_NAME -> + ManifestMetadataClassVisitor(instrumentationContext.apiVersion.get(), nextClassVisitor) + else -> nextClassVisitor + } override fun isInstrumentable(classData: ClassData): Boolean = - classData.className == LOAD_CLASS_NAME + classData.className == LOAD_CLASS_NAME || classData.className == MANIFEST_METADATA_READER_NAME 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> = sortedMapOf( diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt index 70ab8ad07..6274ede53 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt @@ -1,12 +1,17 @@ package io.sentry.android.gradle.tasks +import io.sentry.android.gradle.ManifestMetadataParser import io.sentry.android.gradle.instrumentation.resolveClassAvailability import org.gradle.api.Project import org.gradle.api.UnknownDomainObjectException +import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.artifacts.DefaultModuleIdentifier import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.CacheableTask 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.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider @@ -15,6 +20,10 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { @get:Input abstract val moduleIds: SetProperty + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val mergedManifest: RegularFileProperty + @TaskAction fun generate() { val modules = @@ -26,6 +35,7 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { } .toSet() val availability = resolveClassAvailability(modules) + val metadata = ManifestMetadataParser.parse(mergedManifest.get().asFile) val sourceFile = output.file(GENERATED_CLASS_PATH).get().asFile sourceFile.parentFile.mkdirs() sourceFile.writeText( @@ -47,6 +57,22 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { } return availability; } + + public static Map getManifestMetadata() { + ${ + if (metadata == null) { + " return null;" + } else { + """ Map metadata = new HashMap<>(); + ${ + metadata.toSortedMap().entries.joinToString("\n") { (key, value) -> + " metadata.put(${key.javaStringLiteral()}, ${value.javaLiteral()});" + } + } + return metadata;""" + } + } + } } """ .trimIndent() + "\n" @@ -57,6 +83,32 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { private const val GENERATED_CLASS_PATH = "io/sentry/android/core/SentryGeneratedBuildTimeOptions.java" + private fun String.javaStringLiteral(): String = buildString { + append('"') + this@javaStringLiteral.forEach { character -> + append( + when (character) { + '\\' -> "\\\\" + '"' -> "\\\"" + '\n' -> "\\n" + '\r' -> "\\r" + '\t' -> "\\t" + else -> character + } + ) + } + append('"') + } + + private fun Any.javaLiteral(): String = + when (this) { + is Boolean, + is Int -> toString() + is Float -> "${this}f" + is String -> javaStringLiteral() + else -> error("Unsupported manifest metadata value: $this") + } + fun register( project: Project, configurationName: String, diff --git a/plugin-build/src/test/kotlin/io/sentry/android/core/SentryGeneratedBuildTimeOptions.kt b/plugin-build/src/test/kotlin/io/sentry/android/core/SentryGeneratedBuildTimeOptions.kt index 2cf62dd10..36840d9f5 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/core/SentryGeneratedBuildTimeOptions.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/core/SentryGeneratedBuildTimeOptions.kt @@ -2,6 +2,9 @@ package io.sentry.android.core object SentryGeneratedBuildTimeOptions { var availability: Map = emptyMap() + var metadata: Map? = null @JvmStatic fun getClassAvailability(): Map = availability + + @JvmStatic fun getManifestMetadata(): Map? = metadata } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt new file mode 100644 index 000000000..e8f71b4b5 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt @@ -0,0 +1,79 @@ +package io.sentry.android.gradle + +import com.google.common.truth.Truth.assertThat +import java.io.File +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ManifestMetadataParserTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + @Test + fun `parses sentry metadata with PackageManager value types`() { + val manifest = + manifest( + """ + + + + + + + + """ + ) + + assertThat(ManifestMetadataParser.parse(manifest)) + .containsExactly( + "io.sentry.debug", + true, + "io.sentry.enabled", + false, + "io.sentry.max-breadcrumbs", + 42, + "io.sentry.hex", + -42, + "io.sentry.sample-rate", + 0.5f, + "io.sentry.dsn", + "https://example.invalid/1", + ) + } + + @Test + fun `returns null for values that PackageManager must resolve`() { + assertThat( + ManifestMetadataParser.parse( + manifest("""""") + ) + ) + .isNull() + assertThat( + ManifestMetadataParser.parse( + manifest("""""") + ) + ) + .isNull() + assertThat( + ManifestMetadataParser.parse( + manifest("""""") + ) + ) + .isNull() + } + + private fun manifest(metadata: String): File = + temporaryFolder.newFile("AndroidManifest-${temporaryFolder.root.listFiles()?.size}.xml").apply { + writeText( + """ + + + $metadata + + + """ + .trimIndent() + ) + } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt new file mode 100644 index 000000000..e6b2d65f6 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt @@ -0,0 +1,98 @@ +package io.sentry.android.gradle.instrumentation + +import com.google.common.truth.Truth.assertThat +import io.sentry.android.core.SentryGeneratedBuildTimeOptions +import org.junit.Test +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes + +class ManifestMetadataClassVisitorTest { + @Test + fun `injects generated metadata when static initializer is missing`() { + val metadata = mapOf("io.sentry.debug" to true, "io.sentry.max-breadcrumbs" to 42) + SentryGeneratedBuildTimeOptions.metadata = metadata + + assertThat(readMetadata(load(transformClass()))).containsExactlyEntriesIn(metadata) + } + + @Test + fun `injects metadata and preserves existing static initializer`() { + val metadata = mapOf("io.sentry.debug" to false) + SentryGeneratedBuildTimeOptions.metadata = metadata + + val clazz = load(transformClass(hasStaticInitializer = true)) + + assertThat(clazz.getDeclaredField("marker").getInt(null)).isEqualTo(7) + assertThat(readMetadata(clazz)).containsExactlyEntriesIn(metadata) + } + + @Test + fun `does not modify SDK versions without the metadata field`() { + val clazz = load(transformClass(hasMetadataField = false)) + + assertThat(clazz.declaredFields.map { it.name }).doesNotContain("buildTimeMetadata") + } + + private fun transformClass( + hasMetadataField: Boolean = true, + hasStaticInitializer: Boolean = false, + ): ByteArray { + val original = ClassWriter(0) + original.visit( + Opcodes.V1_8, + Opcodes.ACC_PUBLIC, + READER_INTERNAL_NAME, + null, + "java/lang/Object", + null, + ) + if (hasMetadataField) { + original + .visitField(Opcodes.ACC_STATIC, "buildTimeMetadata", "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, READER_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(ManifestMetadataClassVisitor(Opcodes.ASM9, writer), 0) + return writer.toByteArray() + } + + private fun load(bytes: ByteArray): Class<*> = + object : ClassLoader(javaClass.classLoader) { + fun define(): Class<*> = + defineClass( + SentrySdkOptimizationClassVisitorFactory.MANIFEST_METADATA_READER_NAME, + bytes, + 0, + bytes.size, + ) + } + .define() + + @Suppress("UNCHECKED_CAST") + private fun readMetadata(clazz: Class<*>): Map = + clazz.getDeclaredField("buildTimeMetadata").run { + isAccessible = true + get(null) as Map + } + + private companion object { + const val READER_INTERNAL_NAME = "io/sentry/android/core/ManifestMetadataReader" + } +} 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 35349a586..cef678c2b 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 @@ -581,6 +581,28 @@ class SentryPluginTest : assertFalse("RuntimeClasspath' was resolved during configuration time" in build.output) } + @Test + fun `generates resolved manifest metadata`() { + configureFakeMetadataSdk("true") + + runner.appendArguments(":app:assembleRelease").build() + + assertTrue(releaseDexContains(BUILD_TIME_METADATA_KEY)) + } + + @Test + fun `falls back to PackageManager for resource metadata`() { + configureFakeMetadataSdk("@string/sentry_debug", "android:resource") + File(testProjectDir.root, "app/src/main/res/values/strings.xml").apply { + parentFile.mkdirs() + writeText("true") + } + + runner.appendArguments(":app:assembleRelease").build() + + assertFalse(releaseDexContains(BUILD_TIME_METADATA_KEY)) + } + @Test fun `register tracing instrumentation if tracingInstrumentation is enabled`() { applyTracingInstrumentation() @@ -1358,6 +1380,71 @@ class SentryPluginTest : ) } + private fun configureFakeMetadataSdk(value: String, attribute: String = "android:value") { + File(testProjectDir.root, "settings.gradle") + .appendText("\nproject(':module').name = 'sentry-android-core'") + File( + testProjectDir.root, + "module/src/main/java/io/sentry/android/core/ManifestMetadataReader.java", + ) + .apply { + parentFile.mkdirs() + writeText( + """ + package io.sentry.android.core; + + import java.util.Map; + + public final class ManifestMetadataReader { + static Map buildTimeMetadata; + } + """ + .trimIndent() + ) + } + appBuildFile.appendText( + """ + + dependencies { + implementation project(':sentry-android-core') + } + + android { + buildTypes.release.minifyEnabled = false + } + """ + .trimIndent() + ) + File(testProjectDir.root, "app/src/main/AndroidManifest.xml").apply { + parentFile.mkdirs() + writeText( + """ + + + + + + """ + .trimIndent() + ) + } + } + + private fun releaseDexContains(value: String): Boolean { + val apk = File(testProjectDir.root, "app/build/outputs/apk/release/app-release-unsigned.apk") + return java.util.zip.ZipFile(apk).use { zip -> + zip + .entries() + .asSequence() + .filter { it.name.matches(Regex("classes\\d*\\.dex")) } + .any { entry -> + zip.getInputStream(entry).use { + it.readBytes().toString(Charsets.ISO_8859_1).contains(value) + } + } + } + } + private fun buildDatabaseInstrumentation(vararg dependencies: String): BuildResult { applyTracingInstrumentation( features = setOf(InstrumentationFeature.DATABASE), @@ -1385,6 +1472,7 @@ class SentryPluginTest : } companion object { + private const val BUILD_TIME_METADATA_KEY = "io.sentry.test-build-time-injection" private const val SQLITE = "androidx.sqlite:sqlite:2.6.2" private const val SENTRY_ANDROID_SQLITE_OPEN_HELPER = "io.sentry:sentry-android-sqlite:6.21.0" private val SENTRY_ANDROID_SQLITE_DRIVER = diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt index f68b76ca6..cd077ddf0 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt @@ -12,12 +12,27 @@ class GenerateSentryBuildTimeOptionsTaskTest { @get:Rule val tempDir = TemporaryFolder() @Test - fun `generates class availability source`() { + fun `generates build time options source`() { val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("project")).build() val outputDir = tempDir.newFolder("generated") + val manifest = + tempDir.newFile("AndroidManifest.xml").apply { + writeText( + """ + + + + + + + """ + .trimIndent() + ) + } val task = project.tasks.register("generateOptions", GenerateSentryBuildTimeOptionsTask::class.java) { it.moduleIds.set(setOf("com.jakewharton.timber:timber", "androidx.core:core")) + it.mergedManifest.set(manifest) it.output.set(outputDir) } @@ -28,6 +43,8 @@ class GenerateSentryBuildTimeOptionsTaskTest { assertThat(source).contains("availability.put(\"timber.log.Timber\", true);") assertThat(source).contains("availability.put(\"androidx.core.view.ScrollingView\", true);") assertThat(source).contains("availability.put(\"androidx.lifecycle.Lifecycle\", false);") + assertThat(source).contains("metadata.put(\"io.sentry.debug\", true);") + assertThat(source).contains("metadata.put(\"io.sentry.dsn\", \"quoted&\\\"value\");") } @Test From 9c23001f460decf488e99cb55ab3d820ffaa2c04 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 14 Aug 2026 12:05:02 +0200 Subject: [PATCH 02/10] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 159c05580..ba2f8d77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Avoid resolving the runtime classpath during Gradle configuration when SDK runtime optimizations are enabled ([#1404](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1404)) +### Performance + +- Generate resolved Sentry manifest metadata at build time, reducing SDK initialization time by about 6.5% in an all-options benchmark ([#1405](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1405)) + ### Dependencies - Bump ComposablePreviewScanner from v0.9.2 to v0.9.3 ([#1408](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1408)) From 2dcc0c67fa4ea0fc08ac852a0ffc19c13efb3e7b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 14 Aug 2026 20:36:20 +0200 Subject: [PATCH 03/10] ref(plugin): Target manifest metadata reader Inject generated manifest metadata directly into ManifestMetadataReader while retaining structural compatibility checks. Co-Authored-By: OpenAI Codex --- .../android/gradle/AndroidComponentsConfig.kt | 4 +-- .../ManifestMetadataClassVisitor.kt | 20 +++++++++--- .../GenerateSentryBuildTimeOptionsTask.kt | 4 +++ .../ManifestMetadataClassVisitorTest.kt | 31 +++++++++++++++---- .../gradle/integration/SentryPluginTest.kt | 2 +- .../GenerateSentryBuildTimeOptionsTaskTest.kt | 17 ++++++++-- 6 files changed, 61 insertions(+), 17 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 0baef3d33..cf9ff60b3 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 @@ -209,12 +209,10 @@ fun ApplicationAndroidComponentsExtension.configure( project, "${variant.name}RuntimeClasspath", variant.name.capitalized, + variant.artifacts.get(SingleArtifact.MERGED_MANIFEST), ) val javaSources = variant.sources.java if (buildTimeOptionsTask != null && javaSources != null) { - buildTimeOptionsTask.configure { - it.mergedManifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)) - } javaSources.addGeneratedSourceDirectory( buildTimeOptionsTask, GenerateSentryBuildTimeOptionsTask::output, diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt index 1b01564c5..9f64eb19f 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt @@ -6,7 +6,7 @@ import org.objectweb.asm.FieldVisitor import org.objectweb.asm.MethodVisitor import org.objectweb.asm.Opcodes -/** Injects a call to app-owned build-time manifest metadata into `ManifestMetadataReader`. */ +/** Injects app-owned manifest metadata into the SDK's manifest metadata reader. */ internal class ManifestMetadataClassVisitor(apiVersion: Int, nextClassVisitor: ClassVisitor) : ClassVisitor(apiVersion, nextClassVisitor) { private var hasMetadataField = false @@ -19,7 +19,11 @@ internal class ManifestMetadataClassVisitor(apiVersion: Int, nextClassVisitor: C signature: String?, value: Any?, ): FieldVisitor? { - if (name == METADATA_FIELD && descriptor == MAP_DESCRIPTOR) hasMetadataField = true + if ( + name == METADATA_FIELD && descriptor == MAP_DESCRIPTOR && access and Opcodes.ACC_STATIC != 0 + ) { + hasMetadataField = true + } return super.visitField(access, name, descriptor, signature, value) } @@ -74,14 +78,20 @@ internal class ManifestMetadataClassVisitor(apiVersion: Int, nextClassVisitor: C "()Ljava/util/Map;", false, ) - visitor.visitFieldInsn(Opcodes.PUTSTATIC, READER_INTERNAL_NAME, METADATA_FIELD, MAP_DESCRIPTOR) + visitor.visitFieldInsn( + Opcodes.PUTSTATIC, + MANIFEST_METADATA_READER_INTERNAL_NAME, + METADATA_FIELD, + MAP_DESCRIPTOR, + ) } private companion object { - const val READER_INTERNAL_NAME = "io/sentry/android/core/ManifestMetadataReader" + const val MANIFEST_METADATA_READER_INTERNAL_NAME = + "io/sentry/android/core/ManifestMetadataReader" const val GENERATED_OPTIONS_INTERNAL_NAME = "io/sentry/android/core/SentryGeneratedBuildTimeOptions" - const val METADATA_FIELD = "buildTimeMetadata" + const val METADATA_FIELD = "manifestMetadata" const val MAP_DESCRIPTOR = "Ljava/util/Map;" const val STATIC_INITIALIZER = "" const val VOID_METHOD_DESCRIPTOR = "()V" diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt index 6274ede53..286afc0b5 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt @@ -4,8 +4,10 @@ import io.sentry.android.gradle.ManifestMetadataParser import io.sentry.android.gradle.instrumentation.resolveClassAvailability import org.gradle.api.Project import org.gradle.api.UnknownDomainObjectException +import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.artifacts.DefaultModuleIdentifier +import org.gradle.api.provider.Provider import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input @@ -113,6 +115,7 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { project: Project, configurationName: String, taskSuffix: String, + mergedManifest: Provider, ): TaskProvider? { val configurationProvider = try { @@ -136,6 +139,7 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { } } ) + task.mergedManifest.set(mergedManifest) task.output.set( project.layout.buildDirectory.dir("generated/sentry/buildTimeOptions/$taskSuffix") ) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt index e6b2d65f6..d4f0ce281 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt @@ -31,25 +31,43 @@ class ManifestMetadataClassVisitorTest { fun `does not modify SDK versions without the metadata field`() { val clazz = load(transformClass(hasMetadataField = false)) - assertThat(clazz.declaredFields.map { it.name }).doesNotContain("buildTimeMetadata") + assertThat(clazz.declaredFields.map { it.name }).doesNotContain("manifestMetadata") + } + + @Test + fun `does not modify a non-static metadata field`() { + val clazz = load(transformClass(staticMetadataField = false)) + + Class.forName(clazz.name, true, clazz.classLoader) + assertThat( + java.lang.reflect.Modifier.isStatic(clazz.getDeclaredField("manifestMetadata").modifiers) + ) + .isFalse() } private fun transformClass( hasMetadataField: Boolean = true, + staticMetadataField: Boolean = true, hasStaticInitializer: Boolean = false, ): ByteArray { val original = ClassWriter(0) original.visit( Opcodes.V1_8, Opcodes.ACC_PUBLIC, - READER_INTERNAL_NAME, + MANIFEST_METADATA_READER_INTERNAL_NAME, null, "java/lang/Object", null, ) if (hasMetadataField) { original - .visitField(Opcodes.ACC_STATIC, "buildTimeMetadata", "Ljava/util/Map;", null, null) + .visitField( + if (staticMetadataField) Opcodes.ACC_STATIC else Opcodes.ACC_PUBLIC, + "manifestMetadata", + "Ljava/util/Map;", + null, + null, + ) .visitEnd() } if (hasStaticInitializer) { @@ -59,7 +77,7 @@ class ManifestMetadataClassVisitorTest { original.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null).apply { visitCode() visitIntInsn(Opcodes.BIPUSH, 7) - visitFieldInsn(Opcodes.PUTSTATIC, READER_INTERNAL_NAME, "marker", "I") + visitFieldInsn(Opcodes.PUTSTATIC, MANIFEST_METADATA_READER_INTERNAL_NAME, "marker", "I") visitInsn(Opcodes.RETURN) visitMaxs(1, 0) visitEnd() @@ -87,12 +105,13 @@ class ManifestMetadataClassVisitorTest { @Suppress("UNCHECKED_CAST") private fun readMetadata(clazz: Class<*>): Map = - clazz.getDeclaredField("buildTimeMetadata").run { + clazz.getDeclaredField("manifestMetadata").run { isAccessible = true get(null) as Map } private companion object { - const val READER_INTERNAL_NAME = "io/sentry/android/core/ManifestMetadataReader" + const val MANIFEST_METADATA_READER_INTERNAL_NAME = + "io/sentry/android/core/ManifestMetadataReader" } } 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 cef678c2b..77f64c282 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 @@ -1396,7 +1396,7 @@ class SentryPluginTest : import java.util.Map; public final class ManifestMetadataReader { - static Map buildTimeMetadata; + static Map manifestMetadata; } """ .trimIndent() diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt index cd077ddf0..82137821c 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt @@ -53,7 +53,13 @@ class GenerateSentryBuildTimeOptionsTaskTest { project.plugins.apply("java") val runtimeClasspath = project.configurations.getByName("runtimeClasspath") - GenerateSentryBuildTimeOptionsTask.register(project, "runtimeClasspath", "Test")?.get() + GenerateSentryBuildTimeOptionsTask.register( + project, + "runtimeClasspath", + "Test", + project.layout.file(project.provider { project.file("AndroidManifest.xml") }), + ) + ?.get() assertThat(runtimeClasspath.state).isEqualTo(Configuration.State.UNRESOLVED) } @@ -79,7 +85,14 @@ class GenerateSentryBuildTimeOptionsTaskTest { app.dependencies.project(mapOf("path" to ":sentry-android-replay")), ) - val task = GenerateSentryBuildTimeOptionsTask.register(app, "runtimeClasspath", "Test")!!.get() + val task = + GenerateSentryBuildTimeOptionsTask.register( + app, + "runtimeClasspath", + "Test", + app.layout.file(app.provider { app.file("AndroidManifest.xml") }), + )!! + .get() assertThat(task.moduleIds.get()).contains("io.sentry:sentry-android-replay") } From f66bc28803127c706c8fd170f69f692c1bc4ad37 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 14:31:31 +0200 Subject: [PATCH 04/10] docs: Generalize manifest metadata changelog Describe the initialization improvement without citing benchmark-specific percentages. Co-Authored-By: Codex --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2f8d77b..8c4776c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Performance -- Generate resolved Sentry manifest metadata at build time, reducing SDK initialization time by about 6.5% in an all-options benchmark ([#1405](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1405)) +- Resolve Sentry manifest metadata at build time to reduce Android SDK initialization overhead ([#1405](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1405)) ### Dependencies From 4480d92ba1ddfde9ee3d739cdf41f892ca01ea2f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 25 Aug 2026 11:45:38 +0200 Subject: [PATCH 05/10] ref(plugin): Split build-time option generation tasks Cache class availability and parsed manifest metadata independently so changes to either input avoid repeating unrelated work. Co-Authored-By: Codex --- .../android/gradle/ManifestMetadataParser.kt | 6 +- .../GenerateSentryBuildTimeOptionsTask.kt | 86 +++++++++++++------ .../tasks/ParseSentryManifestMetadataTask.kt | 32 +++++++ .../ResolveSentryClassAvailabilityTask.kt | 37 ++++++++ .../gradle/ManifestMetadataParserTest.kt | 22 +++-- .../GenerateSentryBuildTimeOptionsTaskTest.kt | 76 ++++++++++++++-- 6 files changed, 215 insertions(+), 44 deletions(-) create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ParseSentryManifestMetadataTask.kt create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ResolveSentryClassAvailabilityTask.kt diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt index c2c92e99b..e746dbb91 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt @@ -5,14 +5,14 @@ import javax.xml.parsers.DocumentBuilderFactory import org.w3c.dom.Element internal object ManifestMetadataParser { - fun parse(manifest: File): Map? = + fun parse(manifest: File): Map? = runCatching { val document = manifest.inputStream().buffered().use { DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(it) } val application = document.getElementsByTagName("application").item(0) ?: return emptyMap() - val metadata = linkedMapOf() + val metadata = linkedMapOf() for (index in 0 until application.childNodes.length) { val element = application.childNodes.item(index) as? Element ?: continue @@ -32,7 +32,7 @@ internal object ManifestMetadataParser { ) return null } - metadata[name] = inferType(value) + metadata[name] = value } metadata } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt index 286afc0b5..01d1cc433 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt @@ -1,16 +1,13 @@ package io.sentry.android.gradle.tasks +import groovy.json.JsonSlurper import io.sentry.android.gradle.ManifestMetadataParser -import io.sentry.android.gradle.instrumentation.resolveClassAvailability import org.gradle.api.Project import org.gradle.api.UnknownDomainObjectException import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty -import org.gradle.api.internal.artifacts.DefaultModuleIdentifier import org.gradle.api.provider.Provider -import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity @@ -20,24 +17,41 @@ import org.gradle.api.tasks.TaskProvider @CacheableTask abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { - @get:Input abstract val moduleIds: SetProperty + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val classAvailabilityFile: RegularFileProperty @get:InputFile @get:PathSensitive(PathSensitivity.NONE) - abstract val mergedManifest: RegularFileProperty + abstract val manifestMetadataFile: RegularFileProperty @TaskAction fun generate() { - val modules = - moduleIds - .get() - .mapNotNull { coordinate -> - val parts = coordinate.split(':', limit = 2) - if (parts.size == 2) DefaultModuleIdentifier.newId(parts[0], parts[1]) else null - } - .toSet() - val availability = resolveClassAvailability(modules) - val metadata = ManifestMetadataParser.parse(mergedManifest.get().asFile) + val json = JsonSlurper() + val availability = + (json.parseText(classAvailabilityFile.get().asFile.readText(Charsets.UTF_8)) as Map<*, *>) + .map { (key, value) -> key as String to value as Boolean } + .toMap() + val parsedMetadata = json.parseText(manifestMetadataFile.get().asFile.readText(Charsets.UTF_8)) + val metadata = + if (parsedMetadata == null) { + null + } else { + runCatching { + (parsedMetadata as Map<*, *>) + .map { (key, value) -> + key as String to ManifestMetadataParser.inferType(value as String) + } + .toMap() + } + .onFailure { + logger.info( + "Sentry manifest metadata types could not be inferred for optimization.", + it, + ) + } + .getOrNull() + } val sourceFile = output.file(GENERATED_CLASS_PATH).get().asFile sourceFile.parentFile.mkdirs() sourceFile.writeText( @@ -77,7 +91,8 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { } } """ - .trimIndent() + "\n" + .trimIndent() + "\n", + Charsets.UTF_8, ) } @@ -127,19 +142,38 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { return null } + val intermediateDir = + project.layout.buildDirectory.dir("intermediates/sentry/buildTimeOptions/$taskSuffix") + val availabilityTask = + project.tasks.register( + "resolveSentryClassAvailability$taskSuffix", + ResolveSentryClassAvailabilityTask::class.java, + ) { task -> + task.moduleIds.set( + configurationProvider.map { configuration -> + configuration.incoming.resolutionResult.allComponents.mapNotNullTo(mutableSetOf()) { + component -> + component.moduleVersion?.module?.let { "${it.group}:${it.name}" } + } + } + ) + task.outputFile.set(intermediateDir.map { it.file("class-availability.json") }) + } + val metadataTask = + project.tasks.register( + "parseSentryManifestMetadata$taskSuffix", + ParseSentryManifestMetadataTask::class.java, + ) { task -> + task.mergedManifest.set(mergedManifest) + task.outputFile.set(intermediateDir.map { it.file("manifest-metadata.json") }) + } + return project.tasks.register( "generateSentryBuildTimeOptions$taskSuffix", GenerateSentryBuildTimeOptionsTask::class.java, ) { task -> - task.moduleIds.set( - configurationProvider.map { configuration -> - configuration.incoming.resolutionResult.allComponents.mapNotNullTo(mutableSetOf()) { - component -> - component.moduleVersion?.module?.let { "${it.group}:${it.name}" } - } - } - ) - task.mergedManifest.set(mergedManifest) + task.classAvailabilityFile.set(availabilityTask.flatMap { it.outputFile }) + task.manifestMetadataFile.set(metadataTask.flatMap { it.outputFile }) task.output.set( project.layout.buildDirectory.dir("generated/sentry/buildTimeOptions/$taskSuffix") ) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ParseSentryManifestMetadataTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ParseSentryManifestMetadataTask.kt new file mode 100644 index 000000000..ccc6e2b46 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ParseSentryManifestMetadataTask.kt @@ -0,0 +1,32 @@ +package io.sentry.android.gradle.tasks + +import groovy.json.JsonOutput +import io.sentry.android.gradle.ManifestMetadataParser +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +@CacheableTask +abstract class ParseSentryManifestMetadataTask : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val mergedManifest: RegularFileProperty + + @get:OutputFile abstract val outputFile: RegularFileProperty + + @TaskAction + fun parse() { + val metadata = ManifestMetadataParser.parse(mergedManifest.get().asFile) + val json = JsonOutput.toJson(metadata?.toSortedMap()) + outputFile.get().asFile.apply { + parentFile.mkdirs() + writeText(json, Charsets.UTF_8) + } + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ResolveSentryClassAvailabilityTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ResolveSentryClassAvailabilityTask.kt new file mode 100644 index 000000000..1dc0c500c --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ResolveSentryClassAvailabilityTask.kt @@ -0,0 +1,37 @@ +package io.sentry.android.gradle.tasks + +import groovy.json.JsonOutput +import io.sentry.android.gradle.instrumentation.resolveClassAvailability +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.internal.artifacts.DefaultModuleIdentifier +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +@CacheableTask +abstract class ResolveSentryClassAvailabilityTask : DefaultTask() { + + @get:Input abstract val moduleIds: SetProperty + + @get:OutputFile abstract val outputFile: RegularFileProperty + + @TaskAction + fun resolve() { + val modules = + moduleIds + .get() + .mapNotNull { coordinate -> + val parts = coordinate.split(':', limit = 2) + if (parts.size == 2) DefaultModuleIdentifier.newId(parts[0], parts[1]) else null + } + .toSet() + val json = JsonOutput.toJson(resolveClassAvailability(modules).toSortedMap()) + outputFile.get().asFile.apply { + parentFile.mkdirs() + writeText(json, Charsets.UTF_8) + } + } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt index e8f71b4b5..88f3d7631 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt @@ -10,7 +10,7 @@ class ManifestMetadataParserTest { @get:Rule val temporaryFolder = TemporaryFolder() @Test - fun `parses sentry metadata with PackageManager value types`() { + fun `parses sentry metadata values`() { val manifest = manifest( """ @@ -27,20 +27,30 @@ class ManifestMetadataParserTest { assertThat(ManifestMetadataParser.parse(manifest)) .containsExactly( "io.sentry.debug", - true, + "true", "io.sentry.enabled", - false, + "false", "io.sentry.max-breadcrumbs", - 42, + "42", "io.sentry.hex", - -42, + "-0x2A", "io.sentry.sample-rate", - 0.5f, + "0.5", "io.sentry.dsn", "https://example.invalid/1", ) } + @Test + fun `infers PackageManager value types`() { + assertThat( + listOf("true", "false", "42", "-0x2A", "0.5", "https://example.invalid/1") + .map(ManifestMetadataParser::inferType) + ) + .containsExactly(true, false, 42, -42, 0.5f, "https://example.invalid/1") + .inOrder() + } + @Test fun `returns null for values that PackageManager must resolve`() { assertThat( diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt index 82137821c..9d5849204 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt @@ -15,6 +15,8 @@ class GenerateSentryBuildTimeOptionsTaskTest { fun `generates build time options source`() { val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("project")).build() val outputDir = tempDir.newFolder("generated") + val availabilityFile = tempDir.newFile("class-availability.json") + val metadataFile = tempDir.newFile("manifest-metadata.json") val manifest = tempDir.newFile("AndroidManifest.xml").apply { writeText( @@ -29,14 +31,29 @@ class GenerateSentryBuildTimeOptionsTaskTest { .trimIndent() ) } - val task = - project.tasks.register("generateOptions", GenerateSentryBuildTimeOptionsTask::class.java) { + val availabilityTask = + project.tasks.register( + "resolveAvailability", + ResolveSentryClassAvailabilityTask::class.java, + ) { it.moduleIds.set(setOf("com.jakewharton.timber:timber", "androidx.core:core")) + it.outputFile.set(availabilityFile) + } + val metadataTask = + project.tasks.register("parseMetadata", ParseSentryManifestMetadataTask::class.java) { it.mergedManifest.set(manifest) + it.outputFile.set(metadataFile) + } + val generateTask = + project.tasks.register("generateOptions", GenerateSentryBuildTimeOptionsTask::class.java) { + it.classAvailabilityFile.set(availabilityFile) + it.manifestMetadataFile.set(metadataFile) it.output.set(outputDir) } - task.get().generate() + availabilityTask.get().resolve() + metadataTask.get().parse() + generateTask.get().generate() val source = outputDir.resolve("io/sentry/android/core/SentryGeneratedBuildTimeOptions.java").readText() @@ -47,6 +64,25 @@ class GenerateSentryBuildTimeOptionsTaskTest { assertThat(source).contains("metadata.put(\"io.sentry.dsn\", \"quoted&\\\"value\");") } + @Test + fun `preserves manifest metadata fallback`() { + assertThat(generateSource("null")).contains(" return null;") + } + + @Test + fun `falls back when manifest metadata type cannot be inferred`() { + assertThat(generateSource("""{"io.sentry.max-breadcrumbs":"0xFFFFFFFFFFFFFFFF"}""")) + .contains(" return null;") + } + + @Test + fun `preserves authoritative empty manifest metadata`() { + val source = generateSource("{}") + + assertThat(source).contains("Map metadata = new HashMap<>();") + assertThat(source).doesNotContain("metadata.put(") + } + @Test fun `register does not resolve runtime classpath`() { val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("java-project")).build() @@ -85,15 +121,37 @@ class GenerateSentryBuildTimeOptionsTaskTest { app.dependencies.project(mapOf("path" to ":sentry-android-replay")), ) + GenerateSentryBuildTimeOptionsTask.register( + app, + "runtimeClasspath", + "Test", + app.layout.file(app.provider { app.file("AndroidManifest.xml") }), + )!! + .get() val task = - GenerateSentryBuildTimeOptionsTask.register( - app, - "runtimeClasspath", - "Test", - app.layout.file(app.provider { app.file("AndroidManifest.xml") }), - )!! + app.tasks + .named("resolveSentryClassAvailabilityTest", ResolveSentryClassAvailabilityTask::class.java) .get() assertThat(task.moduleIds.get()).contains("io.sentry:sentry-android-replay") } + + private fun generateSource(metadata: String): String { + val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder()).build() + val availabilityFile = tempDir.newFile().apply { writeText("{}") } + val metadataFile = tempDir.newFile().apply { writeText(metadata) } + val outputDir = tempDir.newFolder() + val task = + project.tasks.register("generateOptions", GenerateSentryBuildTimeOptionsTask::class.java) { + it.classAvailabilityFile.set(availabilityFile) + it.manifestMetadataFile.set(metadataFile) + it.output.set(outputDir) + } + + task.get().generate() + + return outputDir + .resolve("io/sentry/android/core/SentryGeneratedBuildTimeOptions.java") + .readText() + } } From e3cced150dae211947c3d7cdefef64e7276567d9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 25 Aug 2026 11:55:29 +0200 Subject: [PATCH 06/10] ref(plugin): Group runtime optimization tasks Keep the build-time option tasks together under an optimization package as their responsibilities expand. Co-Authored-By: Codex --- .../kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt | 2 +- .../{ => optimization}/GenerateSentryBuildTimeOptionsTask.kt | 3 ++- .../{ => optimization}/ParseSentryManifestMetadataTask.kt | 2 +- .../{ => optimization}/ResolveSentryClassAvailabilityTask.kt | 2 +- .../GenerateSentryBuildTimeOptionsTaskTest.kt | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) rename plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/{ => optimization}/GenerateSentryBuildTimeOptionsTask.kt (98%) rename plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/{ => optimization}/ParseSentryManifestMetadataTask.kt (94%) rename plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/{ => optimization}/ResolveSentryClassAvailabilityTask.kt (95%) rename plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/{ => optimization}/GenerateSentryBuildTimeOptionsTaskTest.kt (99%) 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 cf9ff60b3..a53bb5f90 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 @@ -26,7 +26,6 @@ import io.sentry.android.gradle.snapshot.GenerateSnapshotTestsTask import io.sentry.android.gradle.sourcecontext.OutputPaths import io.sentry.android.gradle.sourcecontext.SourceContext import io.sentry.android.gradle.tasks.GenerateDistributionPropertiesTask -import io.sentry.android.gradle.tasks.GenerateSentryBuildTimeOptionsTask import io.sentry.android.gradle.tasks.InjectSentryMetaPropertiesIntoAssetsTask import io.sentry.android.gradle.tasks.PropertiesFileOutputTask import io.sentry.android.gradle.tasks.SentryGenerateIntegrationListTask @@ -36,6 +35,7 @@ import io.sentry.android.gradle.tasks.SentryUploadProguardMappingsTask import io.sentry.android.gradle.tasks.SentryUploadSnapshotsTask import io.sentry.android.gradle.tasks.configureNativeSymbolsTask import io.sentry.android.gradle.tasks.dependencies.SentryExternalDependenciesReportTaskV2 +import io.sentry.android.gradle.tasks.optimization.GenerateSentryBuildTimeOptionsTask import io.sentry.android.gradle.telemetry.SentryTelemetryService import io.sentry.android.gradle.util.AgpVersions import io.sentry.android.gradle.util.GroovyCompat diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt similarity index 98% rename from plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt rename to plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt index 01d1cc433..bf4568c0f 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt @@ -1,7 +1,8 @@ -package io.sentry.android.gradle.tasks +package io.sentry.android.gradle.tasks.optimization import groovy.json.JsonSlurper import io.sentry.android.gradle.ManifestMetadataParser +import io.sentry.android.gradle.tasks.DirectoryOutputTask import org.gradle.api.Project import org.gradle.api.UnknownDomainObjectException import org.gradle.api.file.RegularFile diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ParseSentryManifestMetadataTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt similarity index 94% rename from plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ParseSentryManifestMetadataTask.kt rename to plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt index ccc6e2b46..b38a24e7b 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ParseSentryManifestMetadataTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt @@ -1,4 +1,4 @@ -package io.sentry.android.gradle.tasks +package io.sentry.android.gradle.tasks.optimization import groovy.json.JsonOutput import io.sentry.android.gradle.ManifestMetadataParser diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ResolveSentryClassAvailabilityTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt similarity index 95% rename from plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ResolveSentryClassAvailabilityTask.kt rename to plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt index 1dc0c500c..f992a9517 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/ResolveSentryClassAvailabilityTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt @@ -1,4 +1,4 @@ -package io.sentry.android.gradle.tasks +package io.sentry.android.gradle.tasks.optimization import groovy.json.JsonOutput import io.sentry.android.gradle.instrumentation.resolveClassAvailability diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt similarity index 99% rename from plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt rename to plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt index 9d5849204..69ef5bdc5 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt @@ -1,4 +1,4 @@ -package io.sentry.android.gradle.tasks +package io.sentry.android.gradle.tasks.optimization import com.google.common.truth.Truth.assertThat import org.gradle.api.artifacts.Configuration From 4595a2790ad946d3b5bec04d85df58e0a7eb609b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 25 Aug 2026 11:57:37 +0200 Subject: [PATCH 07/10] ref(plugin): Keep optimization outputs under Sentry directory Place generated sources and intermediate files below build/sentry so AGP-owned output directories remain separate. Co-Authored-By: Codex --- .../tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt index bf4568c0f..e2f094833 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt @@ -144,7 +144,7 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { } val intermediateDir = - project.layout.buildDirectory.dir("intermediates/sentry/buildTimeOptions/$taskSuffix") + project.layout.buildDirectory.dir("sentry/intermediates/buildTimeOptions/$taskSuffix") val availabilityTask = project.tasks.register( "resolveSentryClassAvailability$taskSuffix", @@ -176,7 +176,7 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { task.classAvailabilityFile.set(availabilityTask.flatMap { it.outputFile }) task.manifestMetadataFile.set(metadataTask.flatMap { it.outputFile }) task.output.set( - project.layout.buildDirectory.dir("generated/sentry/buildTimeOptions/$taskSuffix") + project.layout.buildDirectory.dir("sentry/generated/buildTimeOptions/$taskSuffix") ) } } From 55a570ee411d51f4a0d6428faa50c2b5a4933962 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 25 Aug 2026 12:49:19 +0200 Subject: [PATCH 08/10] test: Accept build-time options cache hit Allow the first configuration-cache test build to restore the cacheable generator task from the shared TestKit build cache. Keep requiring the second build to be up to date. Co-Authored-By: OpenAI Codex --- .../integration/SentryPluginConfigurationCacheTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginConfigurationCacheTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginConfigurationCacheTest.kt index 4ac09066f..ea14cdc41 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginConfigurationCacheTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginConfigurationCacheTest.kt @@ -146,9 +146,10 @@ class SentryPluginConfigurationCacheTest : runner.withArguments("--configuration-cache", "--build-cache", ":app:assembleDebug") val run0 = runner.build() - assertEquals( - TaskOutcome.SUCCESS, - run0.task(":app:generateSentryBuildTimeOptionsDebug")?.outcome, + val run0Outcome = run0.task(":app:generateSentryBuildTimeOptionsDebug")?.outcome + assertTrue( + run0Outcome == TaskOutcome.SUCCESS || run0Outcome == TaskOutcome.FROM_CACHE, + run0.output, ) assertFalse( "Reusing configuration cache." in run0.output || From 03a55fe65f0b4f7116d33511314f09efa7e0a8a2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 25 Aug 2026 14:18:11 +0200 Subject: [PATCH 09/10] ref: Let Gradle create output parent directories Remove redundant directory creation from tasks whose complete output file paths are declared to Gradle. Co-Authored-By: OpenAI Codex --- .../tasks/optimization/ParseSentryManifestMetadataTask.kt | 5 +---- .../tasks/optimization/ResolveSentryClassAvailabilityTask.kt | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt index b38a24e7b..121de8c8a 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt @@ -24,9 +24,6 @@ abstract class ParseSentryManifestMetadataTask : DefaultTask() { fun parse() { val metadata = ManifestMetadataParser.parse(mergedManifest.get().asFile) val json = JsonOutput.toJson(metadata?.toSortedMap()) - outputFile.get().asFile.apply { - parentFile.mkdirs() - writeText(json, Charsets.UTF_8) - } + outputFile.get().asFile.apply { writeText(json, Charsets.UTF_8) } } } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt index f992a9517..ae27b05fd 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt @@ -29,9 +29,6 @@ abstract class ResolveSentryClassAvailabilityTask : DefaultTask() { } .toSet() val json = JsonOutput.toJson(resolveClassAvailability(modules).toSortedMap()) - outputFile.get().asFile.apply { - parentFile.mkdirs() - writeText(json, Charsets.UTF_8) - } + outputFile.get().asFile.apply { writeText(json, Charsets.UTF_8) } } } From 64b4803c775936e23933d0ff738a52814b573b74 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 25 Aug 2026 19:29:41 +0200 Subject: [PATCH 10/10] fix(telemetry): Capture handled metadata inference errors Report build-time manifest metadata inference failures through the opt-out-aware telemetry service while preserving the runtime fallback. Co-Authored-By: OpenAI Codex --- .../android/gradle/AndroidComponentsConfig.kt | 1 + .../GenerateSentryBuildTimeOptionsTask.kt | 14 ++++++++++++++ .../gradle/telemetry/SentryTelemetryService.kt | 15 +++++++++++++-- .../GenerateSentryBuildTimeOptionsTaskTest.kt | 13 +++++++++++-- 4 files changed, 39 insertions(+), 4 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 a53bb5f90..64b2afee5 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 @@ -210,6 +210,7 @@ fun ApplicationAndroidComponentsExtension.configure( "${variant.name}RuntimeClasspath", variant.name.capitalized, variant.artifacts.get(SingleArtifact.MERGED_MANIFEST), + sentryTelemetryProvider, ) val javaSources = variant.sources.java if (buildTimeOptionsTask != null && javaSources != null) { diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt index e2f094833..5f34b29c3 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt @@ -3,13 +3,16 @@ package io.sentry.android.gradle.tasks.optimization import groovy.json.JsonSlurper import io.sentry.android.gradle.ManifestMetadataParser import io.sentry.android.gradle.tasks.DirectoryOutputTask +import io.sentry.android.gradle.telemetry.SentryTelemetryService import org.gradle.api.Project import org.gradle.api.UnknownDomainObjectException import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -26,6 +29,8 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { @get:PathSensitive(PathSensitivity.NONE) abstract val manifestMetadataFile: RegularFileProperty + @get:Internal abstract val sentryTelemetryService: Property + @TaskAction fun generate() { val json = JsonSlurper() @@ -50,6 +55,10 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { "Sentry manifest metadata types could not be inferred for optimization.", it, ) + sentryTelemetryService.orNull?.captureHandledError( + it, + "manifest metadata type inference", + ) } .getOrNull() } @@ -132,6 +141,7 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { configurationName: String, taskSuffix: String, mergedManifest: Provider, + sentryTelemetryProvider: Provider? = null, ): TaskProvider? { val configurationProvider = try { @@ -178,6 +188,10 @@ abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { task.output.set( project.layout.buildDirectory.dir("sentry/generated/buildTimeOptions/$taskSuffix") ) + sentryTelemetryProvider?.let { + task.sentryTelemetryService.set(it) + task.usesService(it) + } } } } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/telemetry/SentryTelemetryService.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/telemetry/SentryTelemetryService.kt index b8bd23b8f..65b42dd29 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/telemetry/SentryTelemetryService.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/telemetry/SentryTelemetryService.kt @@ -147,6 +147,14 @@ abstract class SentryTelemetryService : BuildService, BuildOperationListen } fun captureError(exception: Throwable, operation: String?) { + captureError(exception, operation, handled = false) + } + + fun captureHandledError(exception: Throwable, operation: String?) { + captureError(exception, operation, handled = true) + } + + private fun captureError(exception: Throwable, operation: String?, handled: Boolean) { val message = if (exception is SentryCliException) { "$operation failed with SentryCliException and reason ${exception.reason}" @@ -157,7 +165,7 @@ abstract class SentryTelemetryService : BuildService, BuildOperationListen val mechanism = Mechanism().also { it.type = MECHANISM_TYPE - it.isHandled = false + it.isHandled = handled } val mechanismException: Throwable = ExceptionMechanismException( @@ -165,7 +173,10 @@ abstract class SentryTelemetryService : BuildService, BuildOperationListen SentryMinimalException(message), Thread.currentThread(), ) - val event = SentryEvent(mechanismException).also { it.level = SentryLevel.FATAL } + val event = + SentryEvent(mechanismException).also { + it.level = if (handled) SentryLevel.ERROR else SentryLevel.FATAL + } scopes.captureEvent(event) } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt index 69ef5bdc5..cf9876b1c 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt @@ -1,11 +1,16 @@ package io.sentry.android.gradle.tasks.optimization import com.google.common.truth.Truth.assertThat +import io.sentry.android.gradle.telemetry.SentryTelemetryService import org.gradle.api.artifacts.Configuration import org.gradle.testfixtures.ProjectBuilder import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify class GenerateSentryBuildTimeOptionsTaskTest { @@ -71,8 +76,11 @@ class GenerateSentryBuildTimeOptionsTaskTest { @Test fun `falls back when manifest metadata type cannot be inferred`() { - assertThat(generateSource("""{"io.sentry.max-breadcrumbs":"0xFFFFFFFFFFFFFFFF"}""")) + val telemetry = mock() + + assertThat(generateSource("""{"io.sentry.max-breadcrumbs":"0xFFFFFFFFFFFFFFFF"}""", telemetry)) .contains(" return null;") + verify(telemetry).captureHandledError(any(), eq("manifest metadata type inference")) } @Test @@ -136,7 +144,7 @@ class GenerateSentryBuildTimeOptionsTaskTest { assertThat(task.moduleIds.get()).contains("io.sentry:sentry-android-replay") } - private fun generateSource(metadata: String): String { + private fun generateSource(metadata: String, telemetry: SentryTelemetryService? = null): String { val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder()).build() val availabilityFile = tempDir.newFile().apply { writeText("{}") } val metadataFile = tempDir.newFile().apply { writeText(metadata) } @@ -146,6 +154,7 @@ class GenerateSentryBuildTimeOptionsTaskTest { it.classAvailabilityFile.set(availabilityFile) it.manifestMetadataFile.set(metadataFile) it.output.set(outputDir) + telemetry?.let { service -> it.sentryTelemetryService.set(service) } } task.get().generate()