Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
055a019
fix(build): remove Spring Boot 2 Gradle plugin for Gradle 9 compatibiโ€ฆ
adinauer Apr 2, 2026
9dd4f2f
fix: set duplicatesStrategy=INCLUDE for shadow JAR spring.factories mโ€ฆ
adinauer Apr 2, 2026
63aa458
fix: remove duplicate shadow plugin entry in version catalog
adinauer Apr 2, 2026
0e43fc7
Format code
getsentry-bot Apr 2, 2026
da7d400
fix: update system test runner for shadow JAR compatibility
adinauer Apr 2, 2026
c0acbd7
fix(otel): use DuplicatesStrategy.INCLUDE for otel agent shadow JAR
adinauer Apr 2, 2026
c3c8c24
Exclude test-support modules from api validation
romtsn Apr 7, 2026
76eba12
Verbose system test output and wire inputs for them properly
romtsn Apr 7, 2026
5bfc096
align coroutines version to 1.9.0 for system tests
romtsn Apr 7, 2026
5a29bc6
fix(otel): use mergeServiceFiles path instead of include for Shadow 9.x
romtsn Apr 10, 2026
f47be74
fix(otel): add default mergeServiceFiles for bootstrap service relocaโ€ฆ
romtsn Apr 10, 2026
00c8173
fix(spring-boot2): pre-merge Spring metadata for Shadow 9.x compatibiโ€ฆ
romtsn Apr 10, 2026
3bfe40d
Format code
getsentry-bot Apr 10, 2026
0a95998
fix(lint): suppress OldTargetApi for uitest-android module
romtsn Apr 10, 2026
9a388af
fix(spring-boot2): make mergeSpringMetadata configuration-cache compaโ€ฆ
romtsn Apr 10, 2026
8ae3c9c
formatting
romtsn Apr 10, 2026
4a48a27
fix(spring-boot2): replace from() with doLast JAR patching for springโ€ฆ
romtsn Apr 10, 2026
05c5bac
formatting
romtsn Apr 10, 2026
4bef2eb
fix(spring-boot2): merge AutoConfiguration.imports + doLast JAR patching
romtsn Apr 10, 2026
9fa7649
fix(spring-boot2): also merge ManagementContextConfiguration.imports
romtsn Apr 10, 2026
4a277ae
formatting
romtsn Apr 10, 2026
991221e
fix(spring-boot2): use separate patchSpringMetadata task for JAR patcโ€ฆ
romtsn Apr 10, 2026
500f7f1
formatting
romtsn Apr 10, 2026
cafc487
fix(spring-boot2): revert to doLast on shadowJar for Spring metadata โ€ฆ
romtsn Apr 11, 2026
702dfd0
formatting
romtsn Apr 11, 2026
821da53
fix(spring-boot2): inline Spring metadata merge into shadowJar doLast
romtsn Apr 11, 2026
0e33152
formatting
romtsn Apr 13, 2026
13f278b
fix(build): make Spring sample shadowJar patching config-cache safe
romtsn Apr 13, 2026
247a40c
fix(build): merge Spring metadata properties in shadow jars
romtsn Apr 13, 2026
d184e8b
fix(build): preserve escaped spring metadata keys
romtsn Apr 13, 2026
ab5cfac
refactor(samples): drop no-op spring shadow service merging
romtsn Apr 13, 2026
da63abe
test(android): Avoid ANR profiling integration test race
romtsn Apr 13, 2026
8fd06fb
fix(test): Require actuator health for Spring readiness
romtsn Apr 13, 2026
7b05a2b
ref(build): Share Spring metadata file list
romtsn Apr 13, 2026
327123a
docs(build): Document Spring metadata merge action
romtsn Apr 13, 2026
119b6f8
build(opentelemetry): Fail agent shadow duplicates by default
adinauer Apr 14, 2026
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: 3 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ apiValidation {
"test-app-sentry",
"test-app-size",
"sentry-samples-netflix-dgs",
"sentry-samples-console-otlp"
"sentry-samples-console-otlp",
"sentry-test-support",
"sentry-system-test-support"
)
)
}
Expand Down
292 changes: 292 additions & 0 deletions buildSrc/src/main/java/MergeSpringMetadataAction.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
import java.util.LinkedHashSet
import java.util.zip.ZipFile
import org.gradle.api.Action
import org.gradle.api.Task
import org.gradle.api.file.FileCollection
import org.gradle.api.tasks.bundling.AbstractArchiveTask

/**
* Patches a built shadow JAR by merging Spring metadata and service descriptor files from the
* runtime classpath into the final archive.
*
* Spring metadata files do not all share the same merge semantics, so this action merges
* `spring.factories` as list properties, `.imports` files as line-based metadata, and other Spring
* metadata as key/value properties. It also deduplicates service-provider configuration entries
* under `META-INF/services` so the flat executable JAR keeps the runtime registrations it needs.
*/
class MergeSpringMetadataAction(
private val runtimeClasspath: FileCollection,
private val springMetadataFiles: List<String>,
) : Action<Task> {
companion object {
val DEFAULT_SPRING_METADATA_FILES =
listOf(
"META-INF/spring.factories",
"META-INF/spring.handlers",
"META-INF/spring.schemas",
"META-INF/spring-autoconfigure-metadata.properties",
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
"META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports",
)
}

override fun execute(task: Task) {
val archiveTask = task as AbstractArchiveTask
val jar = archiveTask.archiveFile.get().asFile
val runtimeJars = runtimeClasspath.files.filter { it.name.endsWith(".jar") }
val uri = URI.create("jar:${jar.toURI()}")

FileSystems.newFileSystem(uri, mapOf("create" to "false")).use { fs ->
springMetadataFiles.forEach { entryPath ->
val target = fs.getPath(entryPath)
val contents = mutableListOf<String>()

if (Files.exists(target)) {
contents.add(Files.readString(target))
}

runtimeJars.forEach { depJar ->
try {
ZipFile(depJar).use { zip ->
val entry = zip.getEntry(entryPath)
if (entry != null) {
contents.add(zip.getInputStream(entry).bufferedReader().readText())
}
}
} catch (_: Exception) {
// Ignore non-zip files on the runtime classpath.
}
}

val merged =
when {
entryPath == "META-INF/spring.factories" -> mergeListProperties(contents)
entryPath.endsWith(".imports") -> mergeLineBasedMetadata(contents)
else -> mergeMapProperties(contents)
}

if (merged.isNotEmpty()) {
if (target.parent != null) {
Files.createDirectories(target.parent)
}
Files.write(target, merged.toByteArray())
}
}

val serviceEntries = linkedSetOf<String>()

runtimeJars.forEach { depJar ->
try {
ZipFile(depJar).use { zip ->
val entries = zip.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (!entry.isDirectory && entry.name.startsWith("META-INF/services/")) {
serviceEntries.add(entry.name)
}
}
}
} catch (_: Exception) {
// Ignore non-zip files on the runtime classpath.
}
}

serviceEntries.forEach { entryPath ->
val providers = LinkedHashSet<String>()
val target = fs.getPath(entryPath)

if (Files.exists(target)) {
Files.newBufferedReader(target).useLines { lines ->
lines.forEach { line ->
val provider = line.trim()
if (provider.isNotEmpty() && !provider.startsWith("#")) {
providers.add(provider)
}
}
}
}

runtimeJars.forEach { depJar ->
try {
ZipFile(depJar).use { zip ->
val entry = zip.getEntry(entryPath)
if (entry != null) {
zip.getInputStream(entry).bufferedReader().useLines { lines ->
lines.forEach { line ->
val provider = line.trim()
if (provider.isNotEmpty() && !provider.startsWith("#")) {
providers.add(provider)
}
}
}
}
}
} catch (_: Exception) {
// Ignore non-zip files on the runtime classpath.
}
}

if (providers.isNotEmpty()) {
if (target.parent != null) {
Files.createDirectories(target.parent)
}
Files.write(target, providers.joinToString(separator = "\n", postfix = "\n").toByteArray())
}
}
}
}

private fun mergeLineBasedMetadata(contents: List<String>): String {
val lines = LinkedHashSet<String>()

contents.forEach { content ->
content.lineSequence().forEach { rawLine ->
val line = rawLine.trim()
if (line.isNotEmpty() && !line.startsWith("#")) {
lines.add(line)
}
}
}

return if (lines.isEmpty()) "" else lines.joinToString(separator = "\n", postfix = "\n")
}

private fun mergeMapProperties(contents: List<String>): String {
val merged = linkedMapOf<String, String>()

contents.forEach { content ->
parseProperties(content).forEach { (key, value) ->
merged[key] = value
}
}

return if (merged.isEmpty()) {
""
} else {
merged.entries.joinToString(separator = "\n", postfix = "\n") { (key, value) -> "$key=$value" }
}
}

private fun mergeListProperties(contents: List<String>): String {
val merged = linkedMapOf<String, LinkedHashSet<String>>()

contents.forEach { content ->
parseProperties(content).forEach { (key, value) ->
val values = merged.getOrPut(key) { LinkedHashSet() }
value
.split(',')
.map(String::trim)
.filter(String::isNotEmpty)
.forEach(values::add)
}
}

return if (merged.isEmpty()) {
""
} else {
merged.entries.joinToString(separator = "\n", postfix = "\n") { (key, values) ->
"$key=${values.joinToString(separator = ",")}"
}
}
}

private fun parseProperties(content: String): List<Pair<String, String>> {
val logicalLines = mutableListOf<String>()
val current = StringBuilder()

content.lineSequence().forEach { rawLine ->
val line = rawLine.trim()
if (current.isEmpty() && (line.isEmpty() || line.startsWith("#") || line.startsWith("!"))) {
return@forEach
}

val normalized = if (current.isEmpty()) line else line.trimStart()
current.append(
if (endsWithContinuation(rawLine)) normalized.dropLast(1) else normalized,
)

if (!endsWithContinuation(rawLine)) {
logicalLines.add(current.toString())
current.setLength(0)
}
}

if (current.isNotEmpty()) {
logicalLines.add(current.toString())
}

return logicalLines.map { line ->
val separatorIndex = findSeparatorIndex(line)
if (separatorIndex < 0) {
line to ""
} else {
val keyEnd = trimTrailingWhitespace(line, separatorIndex)
val valueStart = findValueStart(line, separatorIndex)
line.substring(0, keyEnd) to line.substring(valueStart).trim()
}
}
}

private fun endsWithContinuation(line: String): Boolean {
var backslashCount = 0

for (index in line.length - 1 downTo 0) {
if (line[index] == '\\') {
backslashCount++
} else {
break
}
}

return backslashCount % 2 == 1
}

private fun findSeparatorIndex(line: String): Int {
var backslashCount = 0

line.forEachIndexed { index, char ->
if (char == '\\') {
backslashCount++
} else {
val isEscaped = backslashCount % 2 == 1
if (!isEscaped && (char == '=' || char == ':' || char.isWhitespace())) {
return index
}
backslashCount = 0
}
}

return -1
}

private fun trimTrailingWhitespace(line: String, endExclusive: Int): Int {
var end = endExclusive

while (end > 0 && line[end - 1].isWhitespace()) {
end--
}

return end
}

private fun findValueStart(line: String, separatorIndex: Int): Int {
var valueStart = separatorIndex

while (valueStart < line.length && line[valueStart].isWhitespace()) {
valueStart++
}

if (valueStart < line.length && (line[valueStart] == '=' || line[valueStart] == ':')) {
valueStart++
}

while (valueStart < line.length && line[valueStart].isWhitespace()) {
valueStart++
}

return valueStart
}
}
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,13 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" }
jacoco-android = { id = "com.mxalbert.gradle.jacoco-android", version = "0.2.0" }
kover = { id = "org.jetbrains.kotlinx.kover", version = "0.7.3" }
vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" }
springboot2 = { id = "org.springframework.boot", version.ref = "springboot2" }
springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" }
springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" }
spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" }
gretty = { id = "org.gretty", version = "4.0.0" }
animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" }
sentry = { id = "io.sentry.android.gradle", version = "6.0.0-alpha.6"}
shadow = { id = "com.gradleup.shadow", version = "8.3.6" }
shadow = { id = "com.gradleup.shadow", version = "9.4.1" }

[libraries]
apache-httpclient = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.0.4" }
Expand Down Expand Up @@ -159,6 +158,7 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jdk14 = { module = "org.slf4j:slf4j-jdk14", version.ref = "slf4j" }
slf4j2-api = { module = "org.slf4j:slf4j-api", version = "2.0.5" }
spotlessLib = { module = "com.diffplug.spotless:com.diffplug.spotless.gradle.plugin", version.ref = "spotless"}
springboot2-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springboot2" }
springboot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot2" }
springboot-starter-graphql = { module = "org.springframework.boot:spring-boot-starter-graphql", version.ref = "springboot2" }
springboot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springboot2" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ class AnrProfilingIntegrationTest {

val integration = AnrProfilingIntegration()
integration.register(mockScopes, androidOptions)
integration.onForeground()
// Drive the state machine synchronously to avoid racing the background polling thread.

SystemClock.setCurrentTimeMillis(1_000)
integration.checkMainThread(mainThread)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ android {
lint {
warningsAsErrors = true
checkDependencies = true
// Suppress OldTargetApi: lint 8.13.1 expects API 37 but we target 36
disable += "OldTargetApi"

// We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks.
checkReleaseBuilds = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ android {
lint {
warningsAsErrors = true
checkDependencies = true
// Suppress OldTargetApi: lint 8.13.1 expects API 37 but we target 36
disable += "OldTargetApi"

// We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks.
checkReleaseBuilds = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,19 @@ tasks {

duplicatesStrategy = DuplicatesStrategy.FAIL

mergeServiceFiles { include("inst/META-INF/services/*") }
filesMatching("META-INF/services/**") { duplicatesStrategy = DuplicatesStrategy.INCLUDE }
filesMatching("inst/META-INF/services/**") { duplicatesStrategy = DuplicatesStrategy.INCLUDE }

// Shadow 9.x only applies relocations to service files handled by a ServiceFileTransformer.
// We need two mergeServiceFiles calls:
// 1. Default path (META-INF/services) โ€” ensures bootstrap service files get relocated
// (e.g., ContextStorageProvider โ†’ shaded path). Without this, Shadow 9.x skips
// relocation for service file names/contents not claimed by a transformer.
// 2. inst/ path โ€” merges isolated agent service files from both the upstream agent
// and the distro libs. Uses `path` instead of `include` filter because Shadow 9.x's
// include() strips the `inst/` prefix on output.
mergeServiceFiles()
mergeServiceFiles { path = "inst/META-INF/services" }
exclude("**/module-info.class")
relocatePackages(this)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dependencies {
tasks.shadowJar {
manifest { attributes["Main-Class"] = "io.sentry.samples.console.Main" }
archiveClassifier.set("") // Remove the classifier so it replaces the regular JAR
duplicatesStrategy = DuplicatesStrategy.INCLUDE
mergeServiceFiles()
}

Expand All @@ -66,6 +67,10 @@ tasks.register<Test>("systemTest").configure {
group = "verification"
description = "Runs the System tests"

val test = project.extensions.getByType<SourceSetContainer>()["test"]
testClassesDirs = test.output.classesDirs
classpath = test.runtimeClasspath

outputs.upToDateWhen { false }

maxParallelForks = 1
Expand Down
Loading
Loading