Skip to content

Commit e8e50aa

Browse files
dicko2claude
andcommitted
feat: buffer unsent build metrics for offline store-and-forward
Metrics were lost whenever the endpoint was unreachable — exactly the offline and flaky-connection builds we still want to capture. A failed POST now parks the payload under the Gradle user home and the next build that sends successfully flushes it. MetricsPublisher distinguishes three send outcomes: accepted, unreachable (worth retrying), and refused by a reachable endpoint (retrying will not help, so the payload is dropped). Flushing drains oldest first and stops as soon as the endpoint goes unreachable again, so a flaky connection cannot turn into a burst of doomed requests. UnsentMetricsBuffer caps the store at 200 payloads and seven days, and swallows every I/O error. All of it stays off the build's critical path: post() still returns immediately, and the returned future exists only so tests can await the send without sleeping. Closes #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e55d0bd commit e8e50aa

8 files changed

Lines changed: 488 additions & 11 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ or delay the build.
7171
The default endpoint is `http://compilation-metrics/gradle`. Override it with the
7272
`BUILD_METRICS_ES_ENDPOINT` environment variable.
7373

74+
When the endpoint is unreachable the payload is buffered under
75+
`<gradle user home>/kotlin-local-metrics/unsent-build-metrics` and flushed on the next
76+
build that sends successfully, so builds done offline are not lost. The buffer holds at
77+
most 200 payloads and discards anything older than seven days.
78+
7479
## Ktor startup metrics
7580

7681
Add the module to a Ktor server and install the application plugin:

gradle-build-metrics-plugin/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,16 @@ the build.
2222

2323
The default endpoint is `http://compilation-metrics/gradle`. Override it with the
2424
`BUILD_METRICS_ES_ENDPOINT` environment variable.
25+
26+
## Offline buffering
27+
28+
When the endpoint cannot be reached — the developer is offline, the VPN is down, DNS
29+
fails — the payload is written to
30+
`<gradle user home>/kotlin-local-metrics/unsent-build-metrics` instead of being dropped.
31+
The next build whose send succeeds flushes those payloads and deletes each one as it
32+
is accepted, so builds done offline are not lost.
33+
34+
The buffer is bounded: at most 200 payloads, and nothing older than seven days. Older
35+
entries beyond either cap are discarded. A payload that a reachable endpoint refuses is
36+
discarded rather than retried. All buffer I/O is off the build's critical path and, like
37+
the rest of collection, can never fail the build.

gradle-build-metrics-plugin/src/main/kotlin/io/agodadev/localmetrics/gradle/BuildMetricsPlugin.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.agodadev.localmetrics.gradle
22

3+
import java.io.File
34
import javax.inject.Inject
45
import org.gradle.build.event.BuildEventsListenerRegistry
56
import org.gradle.api.Plugin
@@ -31,6 +32,9 @@ public class BuildMetricsPlugin @Inject constructor(
3132
serviceSpec.parameters.rootDirectory.set(target.rootDir)
3233
serviceSpec.parameters.requestedTasks.set(target.gradle.startParameter.taskNames)
3334
serviceSpec.parameters.timeoutMillis.set(HTTP_TIMEOUT_MILLIS)
35+
serviceSpec.parameters.bufferDirectory.set(
36+
File(target.gradle.gradleUserHomeDir, BUFFER_DIRECTORY),
37+
)
3438
}
3539

3640
listenerRegistry.onTaskCompletion(service)
@@ -49,5 +53,6 @@ public class BuildMetricsPlugin @Inject constructor(
4953
const val ENDPOINT_ENVIRONMENT_VARIABLE = "BUILD_METRICS_ES_ENDPOINT"
5054
const val DEFAULT_ENDPOINT = "http://compilation-metrics/gradle"
5155
const val HTTP_TIMEOUT_MILLIS = 2_000
56+
const val BUFFER_DIRECTORY = "kotlin-local-metrics/unsent-build-metrics"
5257
}
5358
}

gradle-build-metrics-plugin/src/main/kotlin/io/agodadev/localmetrics/gradle/BuildMetricsService.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ public abstract class BuildMetricsService :
2525
public val rootDirectory: DirectoryProperty
2626
public val requestedTasks: ListProperty<String>
2727
public val timeoutMillis: Property<Int>
28+
29+
/** Where payloads are parked while the metrics endpoint is unreachable. */
30+
public val bufferDirectory: DirectoryProperty
2831
}
2932

3033
private val taskMetrics = ConcurrentHashMap<String, TaskMetric>()
@@ -74,8 +77,10 @@ public abstract class BuildMetricsService :
7477

7578
MetricsPublisher.post(
7679
endpoint = parameters.endpoint.get(),
80+
payloadId = payload.id,
7781
json = BuildMetricsJson.encode(payload),
7882
timeoutMillis = parameters.timeoutMillis.get(),
83+
buffer = parameters.bufferDirectory.orNull?.asFile?.let(::UnsentMetricsBuffer),
7984
)
8085
}
8186
}
Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package io.agodadev.localmetrics.gradle
22

3+
import java.io.IOException
34
import java.net.URI
45
import java.net.http.HttpClient
56
import java.net.http.HttpRequest
67
import java.net.http.HttpResponse
78
import java.time.Duration
9+
import java.util.concurrent.CompletableFuture
810
import java.util.concurrent.Executors
911
import java.util.concurrent.TimeUnit
12+
import java.util.concurrent.TimeoutException
1013

1114
internal object MetricsPublisher {
1215
private val executor = Executors.newCachedThreadPool { runnable ->
@@ -20,17 +23,117 @@ internal object MetricsPublisher {
2023
.executor(executor)
2124
.build()
2225

23-
fun post(endpoint: String, json: String, timeoutMillis: Int) {
24-
runCatching {
25-
val request = HttpRequest.newBuilder(URI.create(endpoint))
26-
.timeout(Duration.ofMillis(timeoutMillis.toLong()))
27-
.header("Content-Type", "application/json")
28-
.POST(HttpRequest.BodyPublishers.ofString(json))
29-
.build()
30-
31-
client.sendAsync(request, HttpResponse.BodyHandlers.discarding())
32-
.orTimeout(timeoutMillis.toLong(), TimeUnit.MILLISECONDS)
33-
.exceptionally { null }
26+
/**
27+
* Sends [json] and, when [buffer] is present, either buffers it for a later build (the
28+
* endpoint was unreachable) or flushes previously buffered payloads (the send succeeded).
29+
*
30+
* Returns immediately: the returned future exists for tests, callers on the build's
31+
* critical path must not wait on it.
32+
*/
33+
fun post(
34+
endpoint: String,
35+
payloadId: String,
36+
json: String,
37+
timeoutMillis: Int,
38+
buffer: UnsentMetricsBuffer? = null,
39+
): CompletableFuture<Unit> = runCatching {
40+
send(endpoint, json, timeoutMillis).thenCompose { outcome ->
41+
when (outcome) {
42+
SendOutcome.SUCCEEDED -> flush(endpoint, timeoutMillis, buffer)
43+
SendOutcome.UNREACHABLE -> {
44+
buffer?.save(payloadId, json)
45+
completed()
46+
}
47+
SendOutcome.REJECTED -> completed()
48+
}
3449
}
50+
}.getOrElse { completed() }
51+
52+
private fun send(
53+
endpoint: String,
54+
json: String,
55+
timeoutMillis: Int,
56+
): CompletableFuture<SendOutcome> {
57+
val request = HttpRequest.newBuilder(URI.create(endpoint))
58+
.timeout(Duration.ofMillis(timeoutMillis.toLong()))
59+
.header("Content-Type", "application/json")
60+
.POST(HttpRequest.BodyPublishers.ofString(json))
61+
.build()
62+
63+
return client.sendAsync(request, HttpResponse.BodyHandlers.discarding())
64+
.orTimeout(timeoutMillis.toLong(), TimeUnit.MILLISECONDS)
65+
.handle { response, failure ->
66+
when {
67+
failure != null -> if (failure.isConnectivityFailure()) {
68+
SendOutcome.UNREACHABLE
69+
} else {
70+
SendOutcome.REJECTED
71+
}
72+
response.statusCode() in 200..299 -> SendOutcome.SUCCEEDED
73+
else -> SendOutcome.REJECTED
74+
}
75+
}
76+
}
77+
78+
/**
79+
* Drains the buffer one payload at a time, stopping as soon as the endpoint goes
80+
* unreachable again so a flaky connection cannot turn into a burst of doomed requests.
81+
*/
82+
private fun flush(
83+
endpoint: String,
84+
timeoutMillis: Int,
85+
buffer: UnsentMetricsBuffer?,
86+
): CompletableFuture<Unit> {
87+
if (buffer == null) {
88+
return completed()
89+
}
90+
91+
return buffer.list()
92+
.fold(CompletableFuture.completedFuture(true)) { chain, entry ->
93+
chain.thenCompose { reachable ->
94+
val json = if (reachable) buffer.read(entry) else null
95+
when {
96+
!reachable -> CompletableFuture.completedFuture(false)
97+
// An unreadable entry can never be sent; drop it rather than retry forever.
98+
json == null -> {
99+
buffer.delete(entry)
100+
CompletableFuture.completedFuture(true)
101+
}
102+
else -> send(endpoint, json, timeoutMillis).thenApply { outcome ->
103+
if (outcome != SendOutcome.UNREACHABLE) {
104+
// Sent, or refused by a reachable endpoint — either way it is done.
105+
buffer.delete(entry)
106+
}
107+
outcome != SendOutcome.UNREACHABLE
108+
}
109+
}
110+
}
111+
}
112+
.thenApply { }
113+
}
114+
115+
private fun completed(): CompletableFuture<Unit> = CompletableFuture.completedFuture(Unit)
116+
117+
private fun Throwable.isConnectivityFailure(): Boolean {
118+
var failure: Throwable? = this
119+
val seen = mutableSetOf<Throwable>()
120+
while (failure != null && seen.add(failure)) {
121+
if (failure is IOException || failure is TimeoutException) {
122+
return true
123+
}
124+
failure = failure.cause
125+
}
126+
return false
127+
}
128+
129+
private enum class SendOutcome {
130+
/** The endpoint accepted the payload. */
131+
SUCCEEDED,
132+
133+
/** The endpoint could not be reached; the payload is worth retrying later. */
134+
UNREACHABLE,
135+
136+
/** The endpoint was reached but did not accept the payload; retrying will not help. */
137+
REJECTED,
35138
}
36139
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package io.agodadev.localmetrics.gradle
2+
3+
import java.io.File
4+
5+
/**
6+
* A bounded, best-effort store-and-forward buffer for payloads that could not be sent.
7+
*
8+
* One file per payload, named by the payload id. Every operation swallows its errors: a
9+
* developer's build must never fail because a metrics file could not be written or read.
10+
*/
11+
internal class UnsentMetricsBuffer(
12+
private val directory: File,
13+
private val maxEntries: Int = DEFAULT_MAX_ENTRIES,
14+
private val maxAgeMillis: Long = DEFAULT_MAX_AGE_MILLIS,
15+
) {
16+
fun save(id: String, json: String) {
17+
runCatching {
18+
directory.mkdirs()
19+
val entry = File(directory, "${fileName(id)}$EXTENSION")
20+
val temporary = File(directory, "${entry.name}$TEMPORARY_EXTENSION")
21+
temporary.writeText(json)
22+
if (!temporary.renameTo(entry)) {
23+
temporary.copyTo(entry, overwrite = true)
24+
temporary.delete()
25+
}
26+
prune()
27+
}
28+
}
29+
30+
/** Buffered entries, oldest first, so a flush drains them in the order they failed. */
31+
fun list(): List<File> = runCatching {
32+
directory.listFiles { file -> file.isFile && file.name.endsWith(EXTENSION) }
33+
.orEmpty()
34+
.sortedBy(File::lastModified)
35+
}.getOrDefault(emptyList())
36+
37+
fun read(entry: File): String? = runCatching { entry.readText() }.getOrNull()
38+
39+
fun delete(entry: File) {
40+
runCatching { entry.delete() }
41+
}
42+
43+
/** Drops entries older than [maxAgeMillis], then the oldest entries beyond [maxEntries]. */
44+
fun prune(nowMillis: Long = System.currentTimeMillis()) {
45+
runCatching {
46+
val expiredBefore = nowMillis - maxAgeMillis
47+
val (expired, current) = list().partition { it.lastModified() < expiredBefore }
48+
expired.forEach(::delete)
49+
current.dropLast(maxEntries.coerceAtLeast(0)).forEach(::delete)
50+
}
51+
}
52+
53+
private fun fileName(id: String): String {
54+
val sanitized = id
55+
.take(MAX_FILE_NAME_LENGTH)
56+
.map { character ->
57+
if (character.isLetterOrDigit() || character == '-' || character == '_') character else '_'
58+
}
59+
.joinToString(separator = "")
60+
61+
return sanitized.ifBlank { "payload" }
62+
}
63+
64+
private companion object {
65+
const val EXTENSION = ".json"
66+
const val TEMPORARY_EXTENSION = ".tmp"
67+
const val MAX_FILE_NAME_LENGTH = 64
68+
const val DEFAULT_MAX_ENTRIES = 200
69+
const val DEFAULT_MAX_AGE_MILLIS = 7L * 24 * 60 * 60 * 1_000
70+
}
71+
}

0 commit comments

Comments
 (0)