Skip to content

Commit 349c16e

Browse files
DreierFclaude
andcommitted
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=COMPACT|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 d619967 commit 349c16e

19 files changed

Lines changed: 244 additions & 59 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: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import com.teamscale.jacoco.agent.logging.LoggingUtils
66
import com.teamscale.jacoco.agent.options.AgentOptionParseException
77
import com.teamscale.report.ReportUtils
88
import com.teamscale.report.ReportUtils.listFiles
9+
import com.teamscale.report.compact.CompactCoverageReportGenerator
910
import com.teamscale.report.jacoco.EmptyReportException
11+
import com.teamscale.report.jacoco.JaCoCoBasedReportGenerator
1012
import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator
1113
import com.teamscale.report.testwise.ETestArtifactFormat
1214
import com.teamscale.report.testwise.TestwiseCoverageReportWriter
@@ -28,22 +30,38 @@ class Converter
2830
/** The command line arguments. */
2931
private val arguments: ConvertCommand
3032
) {
31-
/** Converts one .exec binary coverage file to XML. */
32-
@Throws(IOException::class)
33+
/**
34+
* Converts one .exec binary coverage file to a coverage report. The output format is selected based on the output
35+
* file extension: `.xml` produces a JaCoCo XML report, `.json` produces a Teamscale Compact Coverage report.
36+
*/
37+
@Throws(IOException::class, AgentOptionParseException::class)
3338
fun runJaCoCoReportGeneration() {
3439
val logger = LoggingUtils.getLogger(this)
35-
val generator = JaCoCoXmlReportGenerator(
36-
arguments.getClassDirectoriesOrZips(),
37-
wildcardIncludeExcludeFilter,
38-
arguments.duplicateClassFileBehavior,
39-
arguments.shouldIgnoreUncoveredClasses,
40-
LoggingUtils.wrap(logger)
41-
)
40+
val outputFile = Paths.get(arguments.outputFile).toFile()
41+
val generator: JaCoCoBasedReportGenerator<*> = when (outputFile.extension.lowercase()) {
42+
"xml" -> JaCoCoXmlReportGenerator(
43+
arguments.getClassDirectoriesOrZips(),
44+
wildcardIncludeExcludeFilter,
45+
arguments.duplicateClassFileBehavior,
46+
arguments.shouldIgnoreUncoveredClasses,
47+
LoggingUtils.wrap(logger)
48+
)
49+
"json" -> CompactCoverageReportGenerator(
50+
arguments.getClassDirectoriesOrZips(),
51+
wildcardIncludeExcludeFilter,
52+
arguments.duplicateClassFileBehavior,
53+
LoggingUtils.wrap(logger)
54+
)
55+
else -> throw AgentOptionParseException(
56+
"Unsupported output file extension '${outputFile.extension}' for '${arguments.outputFile}'. " +
57+
"Use .xml for JaCoCo XML or .json for Teamscale Compact Coverage."
58+
)
59+
}
4260

4361
val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles())
4462
try {
45-
benchmark("Generating the XML report") {
46-
generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile())
63+
benchmark("Generating the coverage report") {
64+
generator.convertExecFilesToReport(jacocoExecutionDataList, outputFile)
4765
}
4866
} catch (e: EmptyReportException) {
4967
logger.warn("Converted report was empty.", e)

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

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ 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.compact.CompactCoverageReportGenerator
38+
import com.teamscale.report.jacoco.JaCoCoBasedReportGenerator
39+
import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator
3740
import com.teamscale.report.util.ClasspathWildcardIncludeFilter
3841
import com.teamscale.report.util.ILogger
3942
import java.io.File
@@ -111,6 +114,13 @@ open class AgentOptions(private val logger: ILogger) {
111114
@JvmField
112115
var mode = EMode.NORMAL
113116

117+
/**
118+
* The report format the agent produces in NORMAL mode.
119+
* Has no effect in TESTWISE mode, which always produces a testwise coverage JSON.
120+
*/
121+
@JvmField
122+
var normalModeReportFormat = EAgentReportFormat.COMPACT
123+
114124
/** The interval in minutes for dumping XML data. */
115125
@JvmField
116126
var dumpIntervalInMinutes = 480
@@ -419,7 +429,8 @@ open class AgentOptions(private val logger: ILogger) {
419429
EUploadMethod.ARTIFACTORY -> createArtifactoryUploader(instrumentation)
420430
EUploadMethod.AZURE_FILE_STORAGE -> AzureFileStorageUploader(
421431
azureFileStorageConfig,
422-
additionalMetaDataFiles
432+
additionalMetaDataFiles,
433+
reportFormat
423434
)
424435
EUploadMethod.SAP_NWDI_TEAMSCALE -> {
425436
logger.info("NWDI configuration detected. The Agent will try and auto-detect commit information by searching all profiled Jar/War/Ear/... files.")
@@ -452,7 +463,7 @@ open class AgentOptions(private val logger: ILogger) {
452463

453464
private fun createTeamscaleSingleProjectUploader(instrumentation: Instrumentation?): IUploader {
454465
if (teamscaleServer.hasCommitOrRevision()) {
455-
return TeamscaleUploader(teamscaleServer)
466+
return TeamscaleUploader(teamscaleServer, reportFormat)
456467
}
457468

458469
val uploader = createDelayedSingleProjectTeamscaleUploader()
@@ -478,7 +489,7 @@ open class AgentOptions(private val logger: ILogger) {
478489
private fun createTeamscaleMultiProjectUploader(
479490
instrumentation: Instrumentation?
480491
): DelayedTeamscaleMultiProjectUploader {
481-
val uploader = DelayedTeamscaleMultiProjectUploader { project, commitInfo ->
492+
val uploader = DelayedTeamscaleMultiProjectUploader(reportFormat) { project, commitInfo ->
482493
if (commitInfo!!.preferCommitDescriptorOverRevision || isEmpty(commitInfo.revision)) {
483494
return@DelayedTeamscaleMultiProjectUploader teamscaleServer.withProjectAndCommit(
484495
project!!,
@@ -544,7 +555,7 @@ open class AgentOptions(private val logger: ILogger) {
544555
} else {
545556
teamscaleServer.revision = projectAndCommit.commitInfo.revision
546557
}
547-
TeamscaleUploader(teamscaleServer)
558+
TeamscaleUploader(teamscaleServer, reportFormat)
548559
}
549560

550561
private fun startMultiGitPropertiesFileSearchInJarFile(
@@ -585,7 +596,8 @@ open class AgentOptions(private val logger: ILogger) {
585596
private fun createNwdiTeamscaleUploader(instrumentation: Instrumentation?): IUploader {
586597
val uploader = DelayedSapNwdiMultiUploader { commit, application ->
587598
TeamscaleUploader(
588-
teamscaleServer.withProjectAndCommit(application.teamscaleProject, commit)
599+
teamscaleServer.withProjectAndCommit(application.teamscaleProject, commit),
600+
reportFormat
589601
)
590602
}
591603
instrumentation?.addTransformer(
@@ -599,7 +611,29 @@ open class AgentOptions(private val logger: ILogger) {
599611
private val reportFormat: EReportFormat
600612
get() = if (useTestwiseCoverageMode()) {
601613
EReportFormat.TESTWISE_COVERAGE
602-
} else EReportFormat.JACOCO
614+
} else normalModeReportFormat.reportFormat
615+
616+
/**
617+
* Creates the report generator used by the agent in NORMAL mode based on [normalModeReportFormat].
618+
* The returned generator only exposes the non-generic base API used by the agent, so the
619+
* `<*>` projection is sufficient at the call site.
620+
*/
621+
fun createReportGenerator(logger: ILogger): JaCoCoBasedReportGenerator<*> =
622+
when (normalModeReportFormat) {
623+
EAgentReportFormat.COMPACT -> CompactCoverageReportGenerator(
624+
classDirectoriesOrZips,
625+
locationIncludeFilter,
626+
duplicateClassFileBehavior,
627+
logger
628+
)
629+
EAgentReportFormat.JACOCO -> JaCoCoXmlReportGenerator(
630+
classDirectoriesOrZips,
631+
locationIncludeFilter,
632+
duplicateClassFileBehavior,
633+
ignoreUncoveredClasses,
634+
logger
635+
)
636+
}
603637

604638
/**
605639
* 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.teamscale.jacoco.agent.options
2+
3+
import com.teamscale.client.EReportFormat
4+
5+
/**
6+
* The coverage report format the agent produces in NORMAL mode.
7+
*
8+
* Each entry bundles the downstream [EReportFormat] used during upload with the on-disk file naming
9+
* convention so that filename, format identifier, and zip-entry name cannot drift apart.
10+
*/
11+
enum class EAgentReportFormat(
12+
/** The [EReportFormat] identifier sent to the upload backend. */
13+
@JvmField val reportFormat: EReportFormat,
14+
/** Filename prefix for files written to the output directory. */
15+
@JvmField val fileNamePrefix: String,
16+
/** Filename extension (without leading dot) for files written to the output directory. */
17+
@JvmField val fileExtension: String,
18+
) {
19+
/** Teamscale Compact Coverage (JSON). */
20+
COMPACT(EReportFormat.TEAMSCALE_COMPACT_COVERAGE, "compact-coverage", "json"),
21+
/** JaCoCo XML. */
22+
JACOCO(EReportFormat.JACOCO, "jacoco", "xml"),
23+
}

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)