Skip to content

Commit 720562f

Browse files
committed
feat: add Gradle build metrics plugin
1 parent 768cfd0 commit 720562f

11 files changed

Lines changed: 682 additions & 6 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Gradle build metrics plugin
2+
3+
Apply the settings-scoped plugin once from `settings.gradle.kts`:
4+
5+
```kotlin
6+
plugins {
7+
id("io.agodadev.kotlin-local-metrics.build") version "<version>"
8+
}
9+
```
10+
11+
The plugin observes Gradle task completion through a shared build service and posts one
12+
payload at the end of each local build. It records:
13+
14+
- task-execution wall-clock and summed task time;
15+
- Kotlin, Java, and Kapt compilation time, including per-project aggregates;
16+
- executed, up-to-date, cache-hit, and failed task counts;
17+
- repository, branch, commit, host, operating system, IDE, and plugin version context.
18+
19+
Collection is skipped when `CI=true`, `GITLAB_CI`, or `CI_JOB_ID` is present. Metrics
20+
are sent asynchronously with a two-second timeout, and collection failures never fail
21+
the build.
22+
23+
The default endpoint is `http://compilation-metrics/gradle`. Override it with the
24+
`BUILD_METRICS_ES_ENDPOINT` environment variable.

gradle-build-metrics-plugin/build.gradle.kts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ plugins {
22
`java-gradle-plugin`
33
}
44

5-
dependencies {
6-
implementation(project(":metrics-core"))
5+
tasks.jar {
6+
manifest {
7+
attributes["Implementation-Version"] = project.version
8+
}
9+
}
710

11+
dependencies {
812
testImplementation(gradleTestKit())
913
testImplementation(kotlin("test-junit5"))
1014
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit.get()}")
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package io.agodadev.localmetrics.gradle
2+
3+
internal object BuildMetricsJson {
4+
fun encode(payload: BuildMetricsPayload): String = buildString {
5+
append('{')
6+
field("id", payload.id)
7+
field("metricsVersion", payload.metricsVersion)
8+
field("userName", payload.userName)
9+
field("cpuCount", payload.cpuCount)
10+
field("hostname", payload.hostname)
11+
field("platform", payload.platform)
12+
field("os", payload.os)
13+
field("timeTaken", payload.timeTaken)
14+
nullableField("branch", payload.branch)
15+
nullableField("commitSha", payload.commitSha)
16+
field("type", payload.type)
17+
field("projectName", payload.projectName)
18+
nullableField("repository", payload.repository)
19+
nullableField("repositoryName", payload.repositoryName)
20+
field("date", payload.date)
21+
field("isDebuggerAttached", payload.isDebuggerAttached)
22+
field("ide", payload.ide)
23+
field("buildKind", payload.buildKind)
24+
stringArrayField("requestedTasks", payload.requestedTasks)
25+
field("taskCount", payload.taskCount)
26+
field("executedTaskCount", payload.executedTaskCount)
27+
field("upToDateTaskCount", payload.upToDateTaskCount)
28+
field("fromCacheTaskCount", payload.fromCacheTaskCount)
29+
field("failedTaskCount", payload.failedTaskCount)
30+
field("compileTaskCount", payload.compileTaskCount)
31+
field("compileTimeMs", payload.compileTimeMs)
32+
field("taskTimeMs", payload.taskTimeMs)
33+
append("\"projects\":[")
34+
payload.projects.forEachIndexed { index, project ->
35+
if (index > 0) append(',')
36+
append('{')
37+
field("projectPath", project.projectPath)
38+
field("compileTaskCount", project.compileTaskCount)
39+
finalField("compileTimeMs", project.compileTimeMs)
40+
append('}')
41+
}
42+
append(']')
43+
append('}')
44+
}
45+
46+
private fun StringBuilder.field(name: String, value: String) {
47+
appendQuoted(name)
48+
append(':')
49+
appendQuoted(value)
50+
append(',')
51+
}
52+
53+
private fun StringBuilder.nullableField(name: String, value: String?) {
54+
appendQuoted(name)
55+
append(':')
56+
if (value == null) append("null") else appendQuoted(value)
57+
append(',')
58+
}
59+
60+
private fun StringBuilder.field(name: String, value: Number) {
61+
appendQuoted(name)
62+
append(':')
63+
append(value)
64+
append(',')
65+
}
66+
67+
private fun StringBuilder.field(name: String, value: Boolean) {
68+
appendQuoted(name)
69+
append(':')
70+
append(value)
71+
append(',')
72+
}
73+
74+
private fun StringBuilder.finalField(name: String, value: Number) {
75+
appendQuoted(name)
76+
append(':')
77+
append(value)
78+
}
79+
80+
private fun StringBuilder.stringArrayField(name: String, values: List<String>) {
81+
appendQuoted(name)
82+
append(":[")
83+
values.forEachIndexed { index, value ->
84+
if (index > 0) append(',')
85+
appendQuoted(value)
86+
}
87+
append("],")
88+
}
89+
90+
private fun StringBuilder.appendQuoted(value: String) {
91+
append('"')
92+
value.forEach { character ->
93+
when (character) {
94+
'"' -> append("\\\"")
95+
'\\' -> append("\\\\")
96+
'\b' -> append("\\b")
97+
'\u000C' -> append("\\f")
98+
'\n' -> append("\\n")
99+
'\r' -> append("\\r")
100+
'\t' -> append("\\t")
101+
else -> if (character < ' ') {
102+
append("\\u")
103+
append(character.code.toString(16).padStart(4, '0'))
104+
} else {
105+
append(character)
106+
}
107+
}
108+
}
109+
append('"')
110+
}
111+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package io.agodadev.localmetrics.gradle
2+
3+
import java.lang.management.ManagementFactory
4+
import java.net.InetAddress
5+
import java.time.Instant
6+
import java.util.UUID
7+
8+
internal data class BuildMetricsPayload(
9+
val id: String,
10+
val metricsVersion: String,
11+
val userName: String,
12+
val cpuCount: Int,
13+
val hostname: String,
14+
val platform: String,
15+
val os: String,
16+
val timeTaken: String,
17+
val branch: String?,
18+
val commitSha: String?,
19+
val type: String,
20+
val projectName: String,
21+
val repository: String?,
22+
val repositoryName: String?,
23+
val date: String,
24+
val isDebuggerAttached: Boolean,
25+
val ide: String,
26+
val buildKind: String,
27+
val requestedTasks: List<String>,
28+
val taskCount: Int,
29+
val executedTaskCount: Int,
30+
val upToDateTaskCount: Int,
31+
val fromCacheTaskCount: Int,
32+
val failedTaskCount: Int,
33+
val compileTaskCount: Int,
34+
val compileTimeMs: Long,
35+
val taskTimeMs: Long,
36+
val projects: List<ProjectCompilationMetrics>,
37+
)
38+
39+
internal data class ProjectCompilationMetrics(
40+
val projectPath: String,
41+
val compileTaskCount: Int,
42+
val compileTimeMs: Long,
43+
)
44+
45+
internal object BuildMetricsPayloadFactory {
46+
fun create(
47+
metricsVersion: String,
48+
projectName: String,
49+
requestedTasks: List<String>,
50+
taskMetrics: List<TaskMetric>,
51+
gitContext: GitContext,
52+
): BuildMetricsPayload {
53+
val compilationTasks = taskMetrics.filter(TaskMetric::isCompilation)
54+
val completedCompilationTasks = compilationTasks.filter { it.didWork() }
55+
val taskExecutionStart = taskMetrics.minOf(TaskMetric::startedAtMillis)
56+
val taskExecutionEnd = taskMetrics.maxOf(TaskMetric::finishedAtMillis)
57+
val upToDateTaskCount = taskMetrics.count { it.outcome == TaskOutcome.UP_TO_DATE }
58+
val fromCacheTaskCount = taskMetrics.count { it.outcome == TaskOutcome.FROM_CACHE }
59+
60+
return BuildMetricsPayload(
61+
id = UUID.randomUUID().toString(),
62+
metricsVersion = metricsVersion,
63+
userName = System.getProperty("user.name").orEmpty(),
64+
cpuCount = Runtime.getRuntime().availableProcessors(),
65+
hostname = hostName(),
66+
platform = System.getProperty("os.name").orEmpty(),
67+
os = listOfNotNull(
68+
System.getProperty("os.name"),
69+
System.getProperty("os.version"),
70+
System.getProperty("os.arch"),
71+
).joinToString(" "),
72+
timeTaken = (taskExecutionEnd - taskExecutionStart).coerceAtLeast(0).toString(),
73+
branch = gitContext.branch,
74+
commitSha = gitContext.commitSha,
75+
type = "Gradle",
76+
projectName = gitContext.repositoryName ?: projectName,
77+
repository = gitContext.repository,
78+
repositoryName = gitContext.repositoryName,
79+
date = Instant.now().toString(),
80+
isDebuggerAttached = ManagementFactory.getRuntimeMXBean().inputArguments
81+
.any { it.contains("jdwp", ignoreCase = true) },
82+
ide = if (System.getProperty("idea.active").toBoolean()) "IntelliJ IDEA" else "CLI",
83+
buildKind = if (upToDateTaskCount + fromCacheTaskCount > 0) "incremental" else "clean",
84+
requestedTasks = requestedTasks,
85+
taskCount = taskMetrics.size,
86+
executedTaskCount = taskMetrics.count { it.outcome == TaskOutcome.EXECUTED },
87+
upToDateTaskCount = upToDateTaskCount,
88+
fromCacheTaskCount = fromCacheTaskCount,
89+
failedTaskCount = taskMetrics.count { it.outcome == TaskOutcome.FAILED },
90+
compileTaskCount = compilationTasks.size,
91+
compileTimeMs = completedCompilationTasks.sumOf(TaskMetric::durationMillis),
92+
taskTimeMs = taskMetrics.sumOf(TaskMetric::durationMillis),
93+
projects = completedCompilationTasks
94+
.groupBy(TaskMetric::projectPath)
95+
.map { (path, tasks) ->
96+
ProjectCompilationMetrics(
97+
projectPath = path,
98+
compileTaskCount = tasks.size,
99+
compileTimeMs = tasks.sumOf(TaskMetric::durationMillis),
100+
)
101+
}
102+
.sortedBy(ProjectCompilationMetrics::projectPath),
103+
)
104+
}
105+
106+
private fun TaskMetric.didWork(): Boolean =
107+
outcome == TaskOutcome.EXECUTED || outcome == TaskOutcome.FAILED
108+
109+
private fun hostName(): String =
110+
System.getenv("HOSTNAME")
111+
?: System.getenv("COMPUTERNAME")
112+
?: runCatching { InetAddress.getLocalHost().hostName }.getOrDefault("")
113+
}
Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,53 @@
11
package io.agodadev.localmetrics.gradle
22

3+
import javax.inject.Inject
4+
import org.gradle.build.event.BuildEventsListenerRegistry
35
import org.gradle.api.Plugin
46
import org.gradle.api.initialization.Settings
7+
import org.gradle.api.provider.ProviderFactory
58

69
/**
710
* Entry point for the settings-scoped build metrics plugin.
8-
*
9-
* Metric collection will be added after the project and publishing scaffold is in place.
1011
*/
11-
public class BuildMetricsPlugin : Plugin<Settings> {
12-
override fun apply(target: Settings) = Unit
12+
public class BuildMetricsPlugin @Inject constructor(
13+
private val listenerRegistry: BuildEventsListenerRegistry,
14+
private val providers: ProviderFactory,
15+
) : Plugin<Settings> {
16+
override fun apply(target: Settings) {
17+
if (isContinuousIntegration()) {
18+
return
19+
}
20+
21+
val service = target.gradle.sharedServices.registerIfAbsent(
22+
SERVICE_NAME,
23+
BuildMetricsService::class.java,
24+
) { serviceSpec ->
25+
serviceSpec.parameters.endpoint.set(
26+
providers.environmentVariable(ENDPOINT_ENVIRONMENT_VARIABLE)
27+
.orElse(DEFAULT_ENDPOINT),
28+
)
29+
serviceSpec.parameters.metricsVersion.set(pluginVersion())
30+
serviceSpec.parameters.projectName.set(target.rootProject.name)
31+
serviceSpec.parameters.rootDirectory.set(target.rootDir)
32+
serviceSpec.parameters.requestedTasks.set(target.gradle.startParameter.taskNames)
33+
serviceSpec.parameters.timeoutMillis.set(HTTP_TIMEOUT_MILLIS)
34+
}
35+
36+
listenerRegistry.onTaskCompletion(service)
37+
}
38+
39+
private fun isContinuousIntegration(): Boolean =
40+
providers.environmentVariable("CI").orNull.equals("true", ignoreCase = true) ||
41+
providers.environmentVariable("GITLAB_CI").isPresent ||
42+
providers.environmentVariable("CI_JOB_ID").isPresent
43+
44+
private fun pluginVersion(): String =
45+
BuildMetricsPlugin::class.java.`package`.implementationVersion ?: "development"
46+
47+
private companion object {
48+
const val SERVICE_NAME = "kotlinLocalBuildMetrics"
49+
const val ENDPOINT_ENVIRONMENT_VARIABLE = "BUILD_METRICS_ES_ENDPOINT"
50+
const val DEFAULT_ENDPOINT = "http://compilation-metrics/gradle"
51+
const val HTTP_TIMEOUT_MILLIS = 2_000
52+
}
1353
}

0 commit comments

Comments
 (0)