metrics4s
Typed, effect-agnostic metrics for Scala 3 on the native Prometheus Java client 1.x. No hardcoded cats-effect, ZIO
or Kyo: the runtime (cats-effect / ZIO / Kyo / rapid) and the backend are modules wired in through a given,
and core depends on nothing.
Install
libraryDependencies ++= Seq(
"now.sendline" %% "metrics4s-cats" % "0.2.0",
"now.sendline" %% "metrics4s-prometheus" % "0.2.0",
)
import metrics4s.runtime.cats.CatsInstances.givenlibraryDependencies ++= Seq(
"now.sendline" %% "metrics4s-zio" % "0.2.0",
"now.sendline" %% "metrics4s-prometheus" % "0.2.0",
)
import metrics4s.runtime.zio.ZioInstances.givenlibraryDependencies ++= Seq(
"now.sendline" %% "metrics4s-kyo" % "0.2.0",
"now.sendline" %% "metrics4s-prometheus" % "0.2.0",
)
import metrics4s.runtime.kyo.KyoInstances.givenlibraryDependencies ++= Seq(
"now.sendline" %% "metrics4s-rapid" % "0.2.0",
"now.sendline" %% "metrics4s-prometheus" % "0.2.0",
)
import metrics4s.runtime.rapid.RapidInstances.givenThe runtime you pick here is remembered on every page and in every library. metrics4s-rapid needs JDK 21 and metrics4s-kyo JDK 25; every other module runs on JDK 17.
At a glance
The conventions a metrics guide asks people to remember are what the constructors do:
- Names are checked where they are written. A counter named
_totalor a duration histogram that does not end in_secondsis a compile error. - A unit is a type.
Histogram.secondsrecords aFiniteDurationand nothing else. - Histograms are classic.
- Labels come from closed sets.
- Series exist from the start. Every series of an enumerated domain is created at zero when the metric is registered.
- One definition, several uses. A metric definition is a plain value, and the Grafana dashboard, the README table and the test all read the same one.
flowchart LR
app["your service"] --> core
subgraph core["metrics4s-core"]
D["Counter · Gauge · Histogram definitions"]
L["LabelValue · Labels"]
E["Effect · Dispatch · Allocated"]
end
RT["runtime<br/>cats-effect · ZIO · Kyo · rapid"] -->|given Effect, Dispatch<br/>runtime metrics| core
PB["metrics4s-prometheus<br/>client 1.x · exporter · JVM"] -->|given Metrics F| core
TK["metrics4s-testkit"] --> PB
GR["metrics4s-grafana<br/>dashboards · README tables"] --> core
INT["integrations<br/>http4s · tapir · sttp · MongoDB"] --> core
Quick start
Runs on JDK 17 or newer. metrics4s-rapid needs 21 and metrics4s-kyo needs 25, like the libraries they wrap.
libraryDependencies ++= Seq(
"now.sendline" %% "metrics4s-cats" % "0.2.0", // core + cats-effect, with its runtime metrics
"now.sendline" %% "metrics4s-prometheus" % "0.2.0", // the backend, the exporter and JVM metrics
"now.sendline" %% "metrics4s-grafana" % "0.2.0", // dashboards and README tables from the definitions
"now.sendline" %% "metrics4s-testkit" % "0.2.0" % Test,
)import scala.concurrent.duration.FiniteDuration
import cats.effect.{IO, IOApp, Resource}
import io.prometheus.metrics.model.registry.PrometheusRegistry
import metrics4s.*
import metrics4s.prometheus.{PrometheusExporter, PrometheusMetrics}
import metrics4s.runtime.cats.CatsRuntimeMetrics
import metrics4s.runtime.cats.CatsInstances.given
import metrics4s.runtime.cats.syntax.given
enum Outcome derives LabelValue:
case Ok, Skipped, Error
object Outcome:
given Labels[Outcome] = Labels.single("outcome")
object ProcessorMetrics:
val processed = Counter("processor_items", "Processed items by outcome").labelled[Outcome]
val processTime = Histogram.seconds("processor_process_seconds", "Processing of one item", Buckets.Normal)
val cacheSize = Gauge("processor_cache_entries", "Entries in the cache")
final class Processor(
processed: CounterOf[IO, Long, Outcome],
processTime: HistogramOf[IO, FiniteDuration, NoLabels],
):
def handle(item: String): IO[Unit] =
processTime.timed(IO.println(item)) >> processed.inc(Outcome.Ok)
object Main extends IOApp.Simple:
def run: IO[Unit] =
val registry = PrometheusRegistry()
given Metrics[IO] = PrometheusMetrics[IO](registry)
val processor =
for
_ <- PrometheusExporter.allocated[IO](9090, registry).toResource
_ <- Resource.eval(PrometheusMetrics.jvm[IO](registry))
_ <- CatsRuntimeMetrics.resource[IO]()
(processed, processTime, cacheSize) <- Resource.eval(
Metrics[IO].registerAll((ProcessorMetrics.processed, ProcessorMetrics.processTime, ProcessorMetrics.cacheSize))
)
_ <- Resource.eval(cacheSize.set(0))
yield Processor(processed, processTime)
processor.use(_.handle("first item"))http://localhost:9090/metrics now serves processor_items_total{outcome="ok"}, {outcome="skipped"} and
{outcome="error"} from the first scrape (the last two at zero), the classic processor_process_seconds_bucket
series, the cats-effect runtime and the JVM.
Swap metrics4s-cats for metrics4s-zio, -kyo or -rapid and the matching *Instances.given import to change
runtime. The definitions and the calls stay the same. RECIPES.md has a ZIO service, labels known only at
startup, errors as a label, a dashboard kept in the repository and alerting stats.
Running it in a service
Three things the compiler cannot check:
- Give every copy of the app its own registry.
PrometheusRegistry.defaultRegistryis global to the JVM, so when a test run starts the app twice, the second copy finds its names taken and fails to register. Create aPrometheusRegistry()and hand the same one to the exporter. - Serve the metrics on a port of their own.
PrometheusExporter.allocated(9464, registry)listens apart from the API, so/metricsnever goes out through the public ingress. A port that is taken fails with anExporterExceptionthat names it. - Label only from closed sets: an enum, a list known at startup (
LabelValue.enumerated), orLabelValue.boundedfor a value the deployment fixes, such as the build's version in anInfo. A user id, a path or an error message adds a series for every value it ever takes.