A simple and intuitive metrics collection library.
μstats is available from maven central for Scala 3.8 and above, for the JVM and Scala Native. Add its coordinates to your build config:
- mill:
ivy"io.crashbox::ustats:<latest_version>" - sbt:
"io.crashbox" %% "ustats" % "<latest_version>"
A monotonically increasing value. Use += to increment.
val requests = ustats.counter.simple("http_requests_total", "Total HTTP requests handled")
requests += 1
// # HELP http_requests_total Total HTTP requests handled
// # TYPE http_requests_total counter
// http_requests_total 1.0A value that can go up and down. Use += and -= to adjust, or set() to assign directly.
val connections = ustats.gauge.simple("active_connections", "Number of active connections")
connections += 1
connections -= 1
connections.set(42)
// # HELP active_connections Number of active connections
// # TYPE active_connections gauge
// active_connections 42.0Counts observations in configurable buckets. Use observe() to record a value. Exposes _bucket, _count, and _sum series. Quantiles can be computed server-side by Prometheus using histogram_quantile().
val latency = ustats.histogram.simple(
"http_request_duration_seconds",
"HTTP request latency",
buckets = Seq(0.01, 0.05, 0.1, 0.5, 1.0)
)
latency.observe(0.043)
// # HELP http_request_duration_seconds HTTP request latency
// # TYPE http_request_duration_seconds histogram
// http_request_duration_seconds_bucket{le="0.01"} 0
// http_request_duration_seconds_bucket{le="0.05"} 1
// http_request_duration_seconds_bucket{le="0.1"} 1
// http_request_duration_seconds_bucket{le="0.5"} 1
// http_request_duration_seconds_bucket{le="1.0"} 1
// http_request_duration_seconds_bucket{le="+Inf"} 1
// http_request_duration_seconds_count 1
// http_request_duration_seconds_sum 0.043Computes quantiles client-side from a sliding window of recent observations. Use observe() to record a value. Exposes _count and _sum series alongside the configured quantiles. Unlike histograms, summaries cannot be aggregated across instances.
val latency = ustats.summary.simple(
"rpc_duration_seconds",
"RPC duration",
quantiles = Seq(0.5, 0.9, 0.99)
)
latency.observe(0.072)
// # HELP rpc_duration_seconds RPC duration
// # TYPE rpc_duration_seconds summary
// rpc_duration_seconds{quantile="0.5"} 0.072
// rpc_duration_seconds{quantile="0.9"} 0.072
// rpc_duration_seconds{quantile="0.99"} 0.072
// rpc_duration_seconds_count 1
// rpc_duration_seconds_sum 0.072A fixed set of key/value labels exposed as a gauge. Useful for version strings and build metadata.
ustats.info(
"build_info",
"Build information",
labelValues = Seq("version" -> "1.2.3", "commit" -> "abc123f")
)
// # HELP build_info Build information
// # TYPE build_info gauge
// build_info{version="1.2.3", commit="abc123f"} 1.0Counters and gauges come in three variants; histograms and summaries support the first two:
Simple (<type>.simple(...)) — a single metric with optional static label values baked in at creation time. Use this when the set of label values is fixed.
val requests = ustats.counter.simple(
"http_requests_total",
"Total HTTP requests handled",
labelValues = Seq("method" -> "GET")
)
requests += 1
// # HELP http_requests_total Total HTTP requests handled
// # TYPE http_requests_total counter
// http_requests_total{method="GET"} 1.0Labelled (<type>(...)) — a family of metrics sharing a basename. Label values are supplied at observation time, and a child metric is created on first use. Use this when label values vary at runtime (e.g. per-endpoint, per-status-code).
val requests = ustats.counter("http_requests_total", "Total HTTP requests", labels = Seq("method", "status"))
requests("GET", "200") += 1
requests("POST", "404") += 1
// # HELP http_requests_total Total HTTP requests
// # TYPE http_requests_total counter
// http_requests_total{method="GET", status="200"} 1.0
// http_requests_total{method="POST", status="404"} 1.0Callback (<type>.callback(...)) — the metric value is read from a user-supplied function each time metrics are scraped. Available for counter and gauge. Use this to bridge values that already exist elsewhere (a JVM bean, a database query, an external library).
ustats.gauge.callback(
"db_pool_size",
"Database connection pool size",
labels = Seq("pool"),
callback = g =>
g("primary")(pool.size.toDouble)
)
// # HELP db_pool_size Database connection pool size
// # TYPE db_pool_size gauge
// db_pool_size{pool="primary"} 5.0ustats can register a standard set of out-of-the-box metrics for the current platform with a single call:
ustats.platform.allMetrics()What gets registered depends on the platform:
JVM — jvm_* and process_* metrics:
| Metric | Type | Description |
|---|---|---|
jvm_threads_current |
gauge | Current thread count |
jvm_threads_daemon |
gauge | Daemon thread count |
jvm_threads_peak |
gauge | Peak thread count |
jvm_threads_started_total |
counter | Total threads started since JVM start |
jvm_threads_deadlocked |
gauge | Threads deadlocked on monitors or synchronizers |
jvm_threads_deadlocked_monitor |
gauge | Threads deadlocked on object monitors |
jvm_threads_state |
gauge | Thread count by state (RUNNABLE, BLOCKED, …) |
jvm_buffer_pool_used_bytes |
gauge | Used bytes per NIO buffer pool |
jvm_buffer_pool_capacity_bytes |
gauge | Capacity bytes per NIO buffer pool |
jvm_buffer_pool_used_buffers |
gauge | Buffer count per NIO buffer pool |
jvm_classes_currently_loaded |
gauge | Currently loaded class count |
jvm_classes_loaded_total |
counter | Total classes loaded since JVM start |
jvm_classes_unloaded_total |
counter | Total classes unloaded since JVM start |
jvm_compilation_time_seconds_total |
counter | Total JIT compilation time |
jvm_gc_collections_total |
counter | GC collection count per collector |
jvm_gc_collection_time_seconds_total |
counter | Total GC time per collector |
jvm_gc_pause_seconds |
histogram | Per-pause GC duration by action and cause |
jvm_memory_used_bytes |
gauge | Used bytes per memory area (heap/nonheap) |
jvm_memory_committed_bytes |
gauge | Committed bytes per memory area |
jvm_memory_max_bytes |
gauge | Max bytes per memory area |
jvm_memory_init_bytes |
gauge | Initial bytes per memory area |
jvm_memory_pool_* |
gauge | Per-memory-pool variants of the above |
jvm_memory_pool_allocated_bytes_total |
counter | Total bytes allocated per memory pool |
jvm_info |
gauge | JVM version, vendor, and runtime name |
JVM and Scala Native — process_* metrics (Linux only, silently omitted elsewhere):
| Metric | Type | Description |
|---|---|---|
process_cpu_seconds_total |
counter | Total user and system CPU time |
process_virtual_memory_bytes |
gauge | Virtual memory size |
process_virtual_memory_max_bytes |
gauge | RLIMIT_AS soft limit (−1 if unlimited) |
process_resident_memory_bytes |
gauge | Resident set size |
process_start_time_seconds |
gauge | Process start time (Unix epoch) |
process_open_fds |
gauge | Open file descriptor count |
process_max_fds |
gauge | RLIMIT_NOFILE soft limit (−1 if unlimited) |
process_threads |
gauge | OS thread count |
Individual sub-groups can also be registered independently:
ustats.jvm.jvmMetrics() // all jvm_* (JVM only)
ustats.jvm.threadMetrics() // jvm_threads_* only
ustats.jvm.memoryMetrics() // jvm_memory_* only
ustats.jvm.gcNotificationMetrics() // jvm_gc_pause_seconds + jvm_memory_pool_allocated_bytes_total
// … etc.
ustats.process.processMetrics() // all process_* (JVM and Native)A Registry holds a set of collectors and can serialize them to the Prometheus
text format. All metrics are registered in Registry.global by default, but you
can create isolated registries and pass them explicitly to metric factory
functions.
val registry = ustats.Registry()
val requests = ustats.counter.simple("http_requests_total", "...", registry = registry)
// write to any OutputStream
registry.writeBytesTo(System.out)
// or get the output as a String
val text: String = registry.metricsAsString()Registry implements geny.Writable, so it can be passed anywhere a source of
bytes is expected — for example directly as an HTTP response body in
cask:
// cask example
@cask.get("/metrics")
def metrics() = registryustats includes a minimal standalone HTTP server that serves the global registry
on the standard /metrics endpoint. It is intentionally bare-bones — if your
application already has an HTTP server, exposing a /metrics route there
directly (using registry.writeBytesTo or the geny.Writable integration) is
preferable to running a second one.
- mill:
ivy"io.crashbox::ustats-server:<latest_version>" - sbt:
"io.crashbox" %% "ustats-server" % "<latest_version>"
// global server for global stats
ustats.server.Server("localhost", 10000).start()
// custom server for custom registry
val registry = ustats.Registry()
ustats.server.Server("localhost", 10000, registry).start()Since metrics may be updated frequently and by multiple concurrent threads, it
is imperative that updates be fast and avoid contention as much as possible.
ustats achieves this by using java.util.concurrent.atomic.DoubleAdders to
store all metrics.
Here are some benchmarks obtained on a laptop with an 13th Gen Intel Core i7-1365U, running OpenJDK 25
# Single threaded, ideal conditions
mill benchmark.runJmh -wi 3 -i 3 -f 1 -t 1
Benchmark Mode Cnt Score Error Units
TestCounter.counter avgt 3 7.609 ± 0.379 ns/op
TestCounter.metrics avgt 3 69.995 ± 1.830 ns/op
# This simulates heavy parallel access with 8 concurrent threads
mill benchmark.runJmh -wi 3 -i 3 -f 1 -t 8
Benchmark Mode Cnt Score Error Units
TestCounter.counter avgt 3 13.216 ± 1.046 ns/op
TestCounter.metrics avgt 3 284.612 ± 19.012 ns/op