jodersky / ustats   0.9.2

BSD 3-clause "New" or "Revised" License GitHub

A simple and intuitive metrics collection library for Prometheus.

Scala versions: 3.x
Scala Native versions: 0.5

μstats

project chat ustats Scala version support stability: soft

A simple and intuitive metrics collection library.

Getting Started

μ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>"

where <latest_version> is latest_version

Metric types

Counter

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.0

Gauge

A 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.0

Histogram

Counts 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.043

Summary

Computes 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.072

Info

A 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.0

Metric variants

Counters 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.0

Labelled (<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.0

Callback (<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.0

Platform metrics

ustats 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:

JVMjvm_* 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 Nativeprocess_* 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)

Registries and serialization

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() = registry

Server

ustats 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()

Benchmarks

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