Skip to content

Commit 6240b1a

Browse files
DreierFclaude
andcommitted
TS-46042 Default to Teamscale Compact Coverage in NORMAL mode
The agent now produces Teamscale Compact Coverage (JSON) by default in NORMAL mode, aligning with the Gradle plugin and reducing report size and upload time. The new orthogonal `report-format=TEAMSCALE_COMPACT_COVERAGE|JACOCO` option lets users fall back to JaCoCo XML. TESTWISE mode is unchanged. The `convert` CLI selects the format from the output file extension. `TeamscaleUploader.reupload` now infers the format from the on-disk file extension so pending retries survive an agent upgrade across the default-flip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0bb5427 commit 6240b1a

20 files changed

Lines changed: 247 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ We use [semantic versioning](http://semver.org/):
55
- PATCH version when you make backwards compatible bug fixes.
66

77
# Next version
8+
- [breaking feature] _agent_: Coverage is now reported in the Teamscale Compact Coverage format by default in NORMAL mode, which is significantly smaller and faster to upload. Set `report-format=JACOCO` in the agent options to keep the previous JaCoCo XML behavior. The `convert` CLI now picks the format from the output file extension (`.xml` for JaCoCo XML, `.json` for Teamscale Compact Coverage).
89

910
# 36.5.2
1011
- [security fix] _agent_: The Teamscale access token was logged in clear text in DEBUG-level logs (e.g., when `debug=true` was set) and in the WARN-level log emitted when multiple `-javaagent` arguments are present. The token is now obfuscated in those logs as well, matching INFO-level behavior.

agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader
1010
import com.teamscale.jacoco.agent.util.AgentUtils
1111
import com.teamscale.report.jacoco.CoverageFile
1212
import com.teamscale.report.jacoco.EmptyReportException
13-
import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator
1413
import com.teamscale.report.jacoco.dump.Dump
1514
import org.glassfish.jersey.server.ResourceConfig
1615
import org.glassfish.jersey.server.ServerProperties
@@ -148,22 +147,17 @@ class Agent(options: AgentOptions, instrumentation: Instrumentation?) : AgentBas
148147
return
149148
}
150149

151-
val generator = JaCoCoXmlReportGenerator(
152-
options.classDirectoriesOrZips,
153-
options.locationIncludeFilter,
154-
options.duplicateClassFileBehavior,
155-
options.ignoreUncoveredClasses,
156-
LoggingUtils.wrap(logger)
157-
)
150+
val format = options.normalModeReportFormat
151+
val generator = options.createReportGenerator(LoggingUtils.wrap(logger))
158152

159153
try {
160-
benchmark("Generating the XML report") {
161-
val outputFile = options.createNewFileInOutputDirectory("jacoco", "xml")
154+
benchmark("Generating the coverage report") {
155+
val outputFile = options.createNewFileInOutputDirectory(format.fileNamePrefix, format.fileExtension)
162156
val coverageFile = generator.convertSingleDumpToReport(dump, outputFile)
163157
uploader.upload(coverageFile)
164158
}
165159
} catch (e: IOException) {
166-
logger.error("Converting binary dump to XML failed", e)
160+
logger.error("Converting binary dump to coverage report failed", e)
167161
} catch (e: EmptyReportException) {
168162
logger.error("No coverage was collected. ${e.message}", e)
169163
}

agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import java.io.IOException
1818
* Encapsulates all command line options for the convert command for parsing with [JCommander].
1919
*/
2020
@Parameters(
21-
commandNames = ["convert"], commandDescription = "Converts a binary .exec coverage file to XML. " +
22-
"Note that the XML report will only contain source file coverage information, but no class coverage."
21+
commandNames = ["convert"], commandDescription = "Converts a binary .exec coverage file to a coverage report. " +
22+
"The output format is selected by the extension of --out: .xml for JaCoCo XML, .json for Teamscale " +
23+
"Compact Coverage. Note that the report will only contain source file coverage information, but no " +
24+
"class coverage."
2325
)
2426
class ConvertCommand : ICommand {
2527
/** The directories and/or zips that contain all class files being profiled. */
@@ -65,11 +67,13 @@ class ConvertCommand : ICommand {
6567
)
6668
var inputFiles = mutableListOf<String>()
6769

68-
/** The directory to write the XML traces to. */
70+
/** The output file. The extension selects the format: .xml = JaCoCo XML, .json = Teamscale Compact Coverage. */
6971
@JvmField
7072
@Parameter(
7173
names = ["--out", "-o"], required = true, description = (""
72-
+ "The file to write the generated XML report to.")
74+
+ "The file to write the generated coverage report to. The file extension selects the format: "
75+
+ ".xml for JaCoCo XML, .json for Teamscale Compact Coverage. When --testwise-coverage is set, "
76+
+ "testwise JSON is produced regardless of the extension.")
7377
)
7478
var outputFile = ""
7579

@@ -86,7 +90,8 @@ class ConvertCommand : ICommand {
8690
@Parameter(
8791
names = ["--ignore-uncovered-classes"], required = false, arity = 1, description = (""
8892
+ "Whether to ignore uncovered classes."
89-
+ " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.")
93+
+ " These classes will not be part of the JaCoCo XML report at all, making it considerably smaller in some cases. Defaults to false. "
94+
+ "Has no effect when the output format is Teamscale Compact Coverage, which never includes uncovered classes.")
9095
)
9196
var shouldIgnoreUncoveredClasses = false
9297

agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import com.teamscale.client.TestDetails
44
import com.teamscale.jacoco.agent.benchmark
55
import com.teamscale.jacoco.agent.logging.LoggingUtils
66
import com.teamscale.jacoco.agent.options.AgentOptionParseException
7+
import com.teamscale.jacoco.agent.options.EAgentReportFormat
78
import com.teamscale.report.ReportUtils
89
import com.teamscale.report.ReportUtils.listFiles
910
import com.teamscale.report.jacoco.EmptyReportException
10-
import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator
1111
import com.teamscale.report.testwise.ETestArtifactFormat
1212
import com.teamscale.report.testwise.TestwiseCoverageReportWriter
1313
import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator
@@ -28,11 +28,20 @@ class Converter
2828
/** The command line arguments. */
2929
private val arguments: ConvertCommand
3030
) {
31-
/** Converts one .exec binary coverage file to XML. */
32-
@Throws(IOException::class)
31+
/**
32+
* Converts one .exec binary coverage file to a coverage report. The output format is selected based on the output
33+
* file extension: `.xml` produces a JaCoCo XML report, `.json` produces a Teamscale Compact Coverage report.
34+
*/
35+
@Throws(IOException::class, AgentOptionParseException::class)
3336
fun runJaCoCoReportGeneration() {
3437
val logger = LoggingUtils.getLogger(this)
35-
val generator = JaCoCoXmlReportGenerator(
38+
val outputFile = Paths.get(arguments.outputFile).toFile()
39+
val format = EAgentReportFormat.fromFileExtension(outputFile.extension)
40+
?: throw AgentOptionParseException(
41+
"Unsupported output file extension '${outputFile.extension}' for '${arguments.outputFile}'. " +
42+
"Use .xml for JaCoCo XML or .json for Teamscale Compact Coverage."
43+
)
44+
val generator = format.createGenerator(
3645
arguments.getClassDirectoriesOrZips(),
3746
wildcardIncludeExcludeFilter,
3847
arguments.duplicateClassFileBehavior,
@@ -42,8 +51,8 @@ class Converter
4251

4352
val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles())
4453
try {
45-
benchmark("Generating the XML report") {
46-
generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile())
54+
benchmark("Generating the coverage report") {
55+
generator.convertExecFilesToReport(jacocoExecutionDataList, outputFile)
4756
}
4857
} catch (e: EmptyReportException) {
4958
logger.warn("Converted report was empty.", e)

agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader
3434
import com.teamscale.jacoco.agent.util.AgentUtils
3535
import com.teamscale.jacoco.agent.util.AgentUtils.mainTempDirectory
3636
import com.teamscale.report.EDuplicateClassFileBehavior
37+
import com.teamscale.report.jacoco.JaCoCoBasedReportGenerator
3738
import com.teamscale.report.util.ClasspathWildcardIncludeFilter
3839
import com.teamscale.report.util.ILogger
3940
import java.io.File
@@ -111,6 +112,13 @@ open class AgentOptions(private val logger: ILogger) {
111112
@JvmField
112113
var mode = EMode.NORMAL
113114

115+
/**
116+
* The report format the agent produces in NORMAL mode.
117+
* Has no effect in TESTWISE mode, which always produces a testwise coverage JSON.
118+
*/
119+
@JvmField
120+
var normalModeReportFormat = EAgentReportFormat.TEAMSCALE_COMPACT_COVERAGE
121+
114122
/** The interval in minutes for dumping XML data. */
115123
@JvmField
116124
var dumpIntervalInMinutes = 480
@@ -419,7 +427,8 @@ open class AgentOptions(private val logger: ILogger) {
419427
EUploadMethod.ARTIFACTORY -> createArtifactoryUploader(instrumentation)
420428
EUploadMethod.AZURE_FILE_STORAGE -> AzureFileStorageUploader(
421429
azureFileStorageConfig,
422-
additionalMetaDataFiles
430+
additionalMetaDataFiles,
431+
reportFormat
423432
)
424433
EUploadMethod.SAP_NWDI_TEAMSCALE -> {
425434
logger.info("NWDI configuration detected. The Agent will try and auto-detect commit information by searching all profiled Jar/War/Ear/... files.")
@@ -452,7 +461,7 @@ open class AgentOptions(private val logger: ILogger) {
452461

453462
private fun createTeamscaleSingleProjectUploader(instrumentation: Instrumentation?): IUploader {
454463
if (teamscaleServer.hasCommitOrRevision()) {
455-
return TeamscaleUploader(teamscaleServer)
464+
return TeamscaleUploader(teamscaleServer, reportFormat)
456465
}
457466

458467
val uploader = createDelayedSingleProjectTeamscaleUploader()
@@ -478,7 +487,7 @@ open class AgentOptions(private val logger: ILogger) {
478487
private fun createTeamscaleMultiProjectUploader(
479488
instrumentation: Instrumentation?
480489
): DelayedTeamscaleMultiProjectUploader {
481-
val uploader = DelayedTeamscaleMultiProjectUploader { project, commitInfo ->
490+
val uploader = DelayedTeamscaleMultiProjectUploader(reportFormat) { project, commitInfo ->
482491
if (commitInfo!!.preferCommitDescriptorOverRevision || isEmpty(commitInfo.revision)) {
483492
return@DelayedTeamscaleMultiProjectUploader teamscaleServer.withProjectAndCommit(
484493
project!!,
@@ -544,7 +553,7 @@ open class AgentOptions(private val logger: ILogger) {
544553
} else {
545554
teamscaleServer.revision = projectAndCommit.commitInfo.revision
546555
}
547-
TeamscaleUploader(teamscaleServer)
556+
TeamscaleUploader(teamscaleServer, reportFormat)
548557
}
549558

550559
private fun startMultiGitPropertiesFileSearchInJarFile(
@@ -585,7 +594,8 @@ open class AgentOptions(private val logger: ILogger) {
585594
private fun createNwdiTeamscaleUploader(instrumentation: Instrumentation?): IUploader {
586595
val uploader = DelayedSapNwdiMultiUploader { commit, application ->
587596
TeamscaleUploader(
588-
teamscaleServer.withProjectAndCommit(application.teamscaleProject, commit)
597+
teamscaleServer.withProjectAndCommit(application.teamscaleProject, commit),
598+
reportFormat
589599
)
590600
}
591601
instrumentation?.addTransformer(
@@ -599,7 +609,17 @@ open class AgentOptions(private val logger: ILogger) {
599609
private val reportFormat: EReportFormat
600610
get() = if (useTestwiseCoverageMode()) {
601611
EReportFormat.TESTWISE_COVERAGE
602-
} else EReportFormat.JACOCO
612+
} else normalModeReportFormat.reportFormat
613+
614+
/** Creates the report generator used by the agent in NORMAL mode based on [normalModeReportFormat]. */
615+
fun createReportGenerator(logger: ILogger): JaCoCoBasedReportGenerator<*> =
616+
normalModeReportFormat.createGenerator(
617+
classDirectoriesOrZips,
618+
locationIncludeFilter,
619+
duplicateClassFileBehavior,
620+
ignoreUncoveredClasses,
621+
logger
622+
)
603623

604624
/**
605625
* Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder

agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ class AgentOptionsParser @VisibleForTesting internal constructor(
311311
options.mode = parseEnumValue(key, value)
312312
return true
313313
}
314+
"report-format" -> {
315+
options.normalModeReportFormat = parseEnumValue(key, value)
316+
return true
317+
}
314318
"includes" -> {
315319
options.jacocoIncludes = value.replace(";".toRegex(), ":")
316320
return true
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.teamscale.jacoco.agent.options
2+
3+
import com.teamscale.client.EReportFormat
4+
import com.teamscale.report.EDuplicateClassFileBehavior
5+
import com.teamscale.report.compact.CompactCoverageReportGenerator
6+
import com.teamscale.report.jacoco.JaCoCoBasedReportGenerator
7+
import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator
8+
import com.teamscale.report.util.ClasspathWildcardIncludeFilter
9+
import com.teamscale.report.util.ILogger
10+
import java.io.File
11+
12+
/**
13+
* The coverage report format the agent produces in NORMAL mode.
14+
*
15+
* Each entry bundles the downstream [EReportFormat] used during upload with the on-disk file naming
16+
* convention so that filename, format identifier, and zip-entry name cannot drift apart.
17+
*/
18+
enum class EAgentReportFormat(
19+
/** The [EReportFormat] identifier sent to the upload backend. */
20+
@JvmField val reportFormat: EReportFormat,
21+
/** Filename prefix for files written to the output directory. */
22+
@JvmField val fileNamePrefix: String,
23+
/** Filename extension (without leading dot) for files written to the output directory. */
24+
@JvmField val fileExtension: String,
25+
) {
26+
/** Teamscale Compact Coverage (JSON). */
27+
TEAMSCALE_COMPACT_COVERAGE(EReportFormat.TEAMSCALE_COMPACT_COVERAGE, "compact-coverage", "json"),
28+
/** JaCoCo XML. */
29+
JACOCO(EReportFormat.JACOCO, "jacoco", "xml");
30+
31+
/** Creates the report generator that produces this format from JaCoCo binary execution data. */
32+
fun createGenerator(
33+
classDirectoriesOrArchives: List<File>,
34+
locationIncludeFilter: ClasspathWildcardIncludeFilter,
35+
duplicateClassFileBehavior: EDuplicateClassFileBehavior,
36+
ignoreUncoveredClasses: Boolean,
37+
logger: ILogger
38+
): JaCoCoBasedReportGenerator<*> = when (this) {
39+
TEAMSCALE_COMPACT_COVERAGE -> CompactCoverageReportGenerator(
40+
classDirectoriesOrArchives, locationIncludeFilter, duplicateClassFileBehavior, logger
41+
)
42+
JACOCO -> JaCoCoXmlReportGenerator(
43+
classDirectoriesOrArchives, locationIncludeFilter, duplicateClassFileBehavior, ignoreUncoveredClasses, logger
44+
)
45+
}
46+
47+
companion object {
48+
/** Returns the format whose [fileExtension] matches the given extension (case-insensitive), or `null`. */
49+
@JvmStatic
50+
fun fromFileExtension(extension: String): EAgentReportFormat? =
51+
entries.firstOrNull { it.fileExtension.equals(extension, ignoreCase = true) }
52+
}
53+
}

agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,6 @@ abstract class HttpZipUploaderBase<T>
138138
}
139139

140140
protected open fun getZipEntryCoverageFileName(coverageFile: CoverageFile): String {
141-
return "coverage.xml"
141+
return coverageFile.name
142142
}
143143
}

agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import java.util.regex.Pattern
3030
/** Uploads the coverage archive to a provided azure file storage. */
3131
class AzureFileStorageUploader(
3232
config: AzureFileStorageConfig,
33-
additionalMetaDataFiles: List<Path>
33+
additionalMetaDataFiles: List<Path>,
34+
private val reportFormat: EReportFormat
3435
) : HttpZipUploaderBase<IAzureUploadApi>(
3536
config.url!!,
3637
additionalMetaDataFiles,
@@ -139,7 +140,7 @@ class AzureFileStorageUploader(
139140

140141
/** Creates a file name for the zip-archive containing the coverage. */
141142
private fun createFileName() =
142-
"${EReportFormat.JACOCO.name.lowercase(Locale.getDefault())}-${System.currentTimeMillis()}.zip"
143+
"${reportFormat.name.lowercase(Locale.getDefault())}-${System.currentTimeMillis()}.zip"
143144

144145
/** Checks if the file with the given name exists */
145146
@Throws(IOException::class, UploaderException::class)

agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.kt

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.teamscale.jacoco.agent.upload.delay
22

33
import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger
4+
import com.teamscale.jacoco.agent.options.EAgentReportFormat
45
import com.teamscale.jacoco.agent.upload.IUploader
56
import com.teamscale.jacoco.agent.util.DaemonThreadFactory
67
import com.teamscale.report.jacoco.CoverageFile
@@ -90,17 +91,19 @@ class DelayedUploader<T> internal constructor(
9091
private fun uploadCachedXmls() {
9192
try {
9293
if (!cacheDir.isDirectory()) {
93-
// Found data before XML was dumped
94+
// Found data before any coverage was dumped
9495
return
9596
}
96-
val xmlFiles = cacheDir.listDirectoryEntries().filter { path ->
97+
val coverageFiles = cacheDir.listDirectoryEntries().filter { path ->
9798
val fileName = path.fileName.toString()
98-
fileName.startsWith("jacoco-") && fileName.endsWith(".xml")
99+
EAgentReportFormat.entries.any { format ->
100+
fileName.startsWith("${format.fileNamePrefix}-") && fileName.endsWith(".${format.fileExtension}")
101+
}
99102
}
100-
xmlFiles.forEach { path -> wrappedUploader?.upload(CoverageFile(path.toFile())) }
101-
logger.debug("Finished upload of cached XMLs to {}", wrappedUploader?.describe())
103+
coverageFiles.forEach { path -> wrappedUploader?.upload(CoverageFile(path.toFile())) }
104+
logger.debug("Finished upload of cached coverage files to {}", wrappedUploader?.describe())
102105
} catch (e: IOException) {
103-
logger.error("Failed to list cached coverage XML files in {}", cacheDir.toAbsolutePath(), e)
106+
logger.error("Failed to list cached coverage files in {}", cacheDir.toAbsolutePath(), e)
104107
}
105108
}
106109
}

0 commit comments

Comments
 (0)