Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,28 @@ curl 'http://localhost:8080/impacted_targets_with_distances?from=main&to=my-feat
}
```

* `GET /metrics` — returns a JSON snapshot of the instance so callers and monitoring can see its
identity, liveness, and cache size usage without scraping logs. Unlike the query endpoints it is
never gated on readiness, so it still responds on an un-ready or lame-ducked instance (the `ready`
field reports the current state). The `cache` size fields are populated for the local-disk backend
and are `null` for a backend whose size is not cheaply knowable in-process.

```bash
curl 'http://localhost:8080/metrics'
```

```json
{
"version": "31.4.0",
"uptimeSeconds": 3600,
"ready": true,
"gitEngine": "jgit",
"trackDeps": false,
"cache": {"directory": "/var/cache/bazel-diff", "entries": 128, "sizeBytes": 4823913, "sizeHuman": "4.6 MB"},
"jvm": {"usedBytes": 123456789, "maxBytes": 2147483648}
}
```

Notes and current limitations:

* Distance metrics (`/impacted_targets_with_distances`) require the dependency-edge graph, which is
Expand Down
6 changes: 6 additions & 0 deletions cli/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ kt_jvm_test(
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "MetricsServiceTest",
test_class = "com.bazel_diff.server.MetricsServiceTest",
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "GitClientTest",
test_class = "com.bazel_diff.server.GitClientTest",
Expand Down
15 changes: 14 additions & 1 deletion cli/src/main/kotlin/com/bazel_diff/cli/ServeCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.bazel_diff.server.HashService
import com.bazel_diff.server.ImpactedTargetsService
import com.bazel_diff.server.JGitClient
import com.bazel_diff.server.LocalDiskHashCacheStorage
import com.bazel_diff.server.MetricsService
import com.bazel_diff.server.ProcessGitClient
import com.bazel_diff.server.PrunableHashCacheStorage
import java.io.File
Expand Down Expand Up @@ -336,8 +337,20 @@ class ServeCommand : Callable<Int> {
ImpactedTargetsService(gitClient, hashService, depsTracked = trackDeps)

val ready = AtomicBoolean(false)
val metrics =
MetricsService(
version = VersionProvider().version.firstOrNull() ?: "unknown",
startedAtMillis = System.currentTimeMillis(),
gitEngine = gitEngine,
trackDeps = trackDeps,
cacheDir = cacheDir.toString(),
storage = storage,
readiness = { ready.get() },
)
val server =
BazelDiffServer(port, impactedTargetsService, requestTimeoutSeconds) { ready.get() }
BazelDiffServer(port, impactedTargetsService, requestTimeoutSeconds, metrics) {
ready.get()
}
server.start()
performInitialFetch(gitClient, hashService, ready, server)
// Start sweeping only after warmup, so the immediate first pass evaluates an already-warm
Expand Down
26 changes: 23 additions & 3 deletions cli/src/main/kotlin/com/bazel_diff/server/BazelDiffServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import org.koin.core.component.inject
* - `GET /impacted_targets_with_distances?from=<rev>&to=<rev>[&targetType=...]` -- like above but
* each impacted target is `{"label", "targetDistance", "packageDistance"}`. Requires the server
* to have been started with `--trackDeps`; returns `400` otherwise.
* - `GET /metrics` -- a JSON snapshot of the instance (version, uptime, readiness, git engine,
* cache size usage, JVM heap) when a [metricsProvider] is wired. Intentionally not gated on
* readiness, so a scrape of an un-ready or lame-ducked instance still returns data.
*
* Built on the JDK's [HttpServer] so the service needs no new third-party dependency. The handler
* pool is unbounded (cached) so that health checks are always served even while a long hash
Expand All @@ -38,6 +41,7 @@ class BazelDiffServer(
private val port: Int,
private val impactedTargetsProvider: ImpactedTargetsProvider,
private val requestTimeoutSeconds: Long = 0,
private val metricsProvider: MetricsProvider? = null,
private val readiness: () -> Boolean,
) : KoinComponent {
private val logger: Logger by inject()
Expand Down Expand Up @@ -66,6 +70,7 @@ class BazelDiffServer(
httpServer.createContext("/impacted_targets", ::handleImpactedTargets)
httpServer.createContext(
"/impacted_targets_with_distances", ::handleImpactedTargetsWithDistances)
httpServer.createContext("/metrics", ::handleMetrics)
httpServer.start()
server = httpServer
logger.i { "bazel-diff query service listening on port ${boundPort()} " }
Expand Down Expand Up @@ -99,6 +104,21 @@ class BazelDiffServer(
}
}

private fun handleMetrics(exchange: HttpExchange) =
withExchange(exchange) {
if (!exchange.requestMethod.equals("GET", ignoreCase = true)) {
respondJson(exchange, 405, mapOf("error" to "method not allowed, use GET"))
return@withExchange
}
val provider = metricsProvider
if (provider == null) {
respondJson(exchange, 404, mapOf("error" to "metrics unavailable"))
return@withExchange
}
// Deliberately not gated on readiness: metrics must be scrapeable even when un-ready.
respondJson(exchange, 200, provider.snapshot())
}

private fun handleImpactedTargets(exchange: HttpExchange) =
handleQuery(exchange) { from, to, targetTypes ->
impactedTargetsProvider.getImpactedTargets(from, to, targetTypes)
Expand Down Expand Up @@ -158,9 +178,9 @@ class BazelDiffServer(

/**
* Runs [compute] bounded by [requestTimeoutSeconds]. When the budget is exceeded the in-flight
* task is interrupted and a [TimeoutException] is thrown so the caller can respond `504`. Note the
* underlying `bazel query` may not honor the interrupt and can keep running in the background
* abandoning it frees the client and the handler thread, and the query will still populate the
* task is interrupted and a [TimeoutException] is thrown so the caller can respond `504`. Note
* the underlying `bazel query` may not honor the interrupt and can keep running in the background
* abandoning it frees the client and the handler thread, and the query will still populate the
* per-SHA cache so a retry is fast. A non-positive timeout (the default) runs [compute] inline on
* the handler thread with no bound, preserving the original behavior.
*/
Expand Down
36 changes: 35 additions & 1 deletion cli/src/main/kotlin/com/bazel_diff/server/HashCacheStorage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ interface HashCacheStorage {
fun contains(key: String): Boolean = get(key) != null
}

/** Footprint of a cache: how many entries are stored and their total size in bytes. */
data class CacheStorageStats(val entryCount: Long, val totalBytes: Long)

/**
* A [HashCacheStorage] that can cheaply report its footprint, surfaced by the `/metrics` endpoint
* so callers can watch cache growth. Backends where size is not cheaply knowable in-process (e.g. a
* remote store) simply do not implement this, and metrics report the size as unavailable.
*/
interface MeasurableHashCacheStorage : HashCacheStorage {
fun stats(): CacheStorageStats
}

/**
* Bounds a [PrunableHashCacheStorage] may be pruned down to. Each dimension is independent; a null
* field means "no limit on that dimension", and a value of all-null disables pruning entirely (see
Expand Down Expand Up @@ -75,7 +87,8 @@ interface PrunableHashCacheStorage : HashCacheStorage {
* age since generation -- a base revision queried every CI run stays warm and is not expired out
* from under active traffic.
*/
class LocalDiskHashCacheStorage(private val directory: Path) : PrunableHashCacheStorage {
class LocalDiskHashCacheStorage(private val directory: Path) :
MeasurableHashCacheStorage, PrunableHashCacheStorage {
init {
Files.createDirectories(directory)
}
Expand Down Expand Up @@ -112,6 +125,27 @@ class LocalDiskHashCacheStorage(private val directory: Path) : PrunableHashCache

override fun contains(key: String): Boolean = Files.isRegularFile(pathFor(key))

override fun stats(): CacheStorageStats {
if (!Files.isDirectory(directory)) return CacheStorageStats(0, 0)
var entryCount = 0L
var totalBytes = 0L
// Only the `<key>.json` cache entries count -- in-progress `.tmp` writes are excluded, matching
// what a cache read would ever see.
Files.newDirectoryStream(directory, "*.json").use { stream ->
for (path in stream) {
try {
if (Files.isRegularFile(path)) {
entryCount++
totalBytes += Files.size(path)
}
} catch (e: IOException) {
// An entry that vanished mid-scan just doesn't count; keep tallying the rest.
}
}
}
return CacheStorageStats(entryCount, totalBytes)
}

override fun prune(limits: CachePruneLimits): CachePruneResult {
if (!limits.hasAny) return CachePruneResult(0, 0, 0)

Expand Down
102 changes: 102 additions & 0 deletions cli/src/main/kotlin/com/bazel_diff/server/MetricsService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.bazel_diff.server

import java.util.Locale

/**
* A point-in-time snapshot of a running server instance, returned by `GET /metrics` so operators
* and callers can see the instance's identity, liveness, and resource usage without scraping logs.
*/
data class ServerMetrics(
val version: String,
val uptimeSeconds: Long,
val ready: Boolean,
val gitEngine: String,
val trackDeps: Boolean,
val cache: CacheMetrics,
val jvm: JvmMetrics,
)

/**
* Footprint of the hash cache. [entries]/[sizeBytes]/[sizeHuman] are null when the backend does not
* cheaply report its size (e.g. a remote store) -- see [MeasurableHashCacheStorage].
*/
data class CacheMetrics(
val directory: String?,
val entries: Long?,
val sizeBytes: Long?,
val sizeHuman: String?,
)

/** JVM heap usage of the instance, in bytes. */
data class JvmMetrics(val usedBytes: Long, val maxBytes: Long)

/**
* Supplies a fresh [ServerMetrics] snapshot. Behind an interface so the HTTP layer can be tested
* with a fake.
*/
interface MetricsProvider {
fun snapshot(): ServerMetrics
}

/**
* [MetricsProvider] that assembles a snapshot from the instance's configuration, its readiness
* flag, live JVM state, and -- when the backing [storage] supports it
* ([MeasurableHashCacheStorage]) -- the on-disk cache footprint. The cache size is read on demand
* per request; `/metrics` is expected to be polled by monitoring at a low rate, so walking the
* cache directory each call is acceptable.
*
* @param clock source of "now" in epoch millis, injectable so uptime is deterministic under test.
* @param readiness the same liveness flag `/health` reports; surfaced here so a scrape of an
* un-ready or lame-ducked instance still returns data (metrics are intentionally not gated on
* it).
*/
class MetricsService(
private val version: String,
private val startedAtMillis: Long,
private val gitEngine: String,
private val trackDeps: Boolean,
private val cacheDir: String,
private val storage: HashCacheStorage,
private val readiness: () -> Boolean,
private val clock: () -> Long = System::currentTimeMillis,
) : MetricsProvider {
override fun snapshot(): ServerMetrics {
val cacheStats = (storage as? MeasurableHashCacheStorage)?.stats()
val runtime = Runtime.getRuntime()
return ServerMetrics(
version = version,
uptimeSeconds = (clock() - startedAtMillis).coerceAtLeast(0) / 1000,
ready = readiness(),
gitEngine = gitEngine,
trackDeps = trackDeps,
cache =
CacheMetrics(
directory = cacheDir,
entries = cacheStats?.entryCount,
sizeBytes = cacheStats?.totalBytes,
sizeHuman = cacheStats?.totalBytes?.let(::humanReadableBytes),
),
jvm =
JvmMetrics(
usedBytes = runtime.totalMemory() - runtime.freeMemory(),
maxBytes = runtime.maxMemory(),
),
)
}

/**
* Formats a byte count as a compact base-1024 string (matching the `--cacheMaxSize` unit scale),
* e.g. `4.6 MB`.
*/
private fun humanReadableBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = listOf("KB", "MB", "GB", "TB")
var value = bytes.toDouble() / 1024
var unit = 0
while (value >= 1024 && unit < units.size - 1) {
value /= 1024
unit++
}
return String.format(Locale.US, "%.1f %s", value, units[unit])
}
}
26 changes: 26 additions & 0 deletions cli/src/test/kotlin/com/bazel_diff/cli/ServeCommandTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import com.bazel_diff.server.HashCacheStorage
import com.bazel_diff.server.JGitClient
import com.bazel_diff.server.LocalDiskHashCacheStorage
import com.bazel_diff.server.ProcessGitClient
import com.bazel_diff.server.ServerMetrics
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import java.net.HttpURLConnection
import java.net.URL
Expand Down Expand Up @@ -323,4 +325,28 @@ class ServeCommandTest : KoinTest {
// Fetch failed, so the instance stays un-ready and reports 503 for the load balancer to remove.
assertThat(healthCode(server)).isEqualTo(503)
}

@Test
fun metricsEndpointReportsInstanceAndCacheUsage() {
val cmd = command()
// Back the service with a real on-disk cache under the configured --cacheDir so the endpoint
// reports live size usage through the full ServeCommand -> BazelDiffServer -> storage wiring.
val storage = LocalDiskHashCacheStorage(cmd.cacheDir)
storage.put("k", ByteArray(64))
val server = cmd.buildAndStartServer(FakeGitClient(), storage).also { startedServers += it }

val conn =
URL("http://localhost:${server.boundPort()}/metrics").openConnection() as HttpURLConnection
try {
assertThat(conn.responseCode).isEqualTo(200)
val parsed =
Gson().fromJson(conn.inputStream.bufferedReader().readText(), ServerMetrics::class.java)
assertThat(parsed.cache.directory).isEqualTo(cmd.cacheDir.toString())
assertThat(parsed.cache.entries).isEqualTo(1L)
assertThat(parsed.cache.sizeBytes).isEqualTo(64L)
assertThat(parsed.ready).isEqualTo(true)
} finally {
conn.disconnect()
}
}
}
Loading
Loading