SENDLINE open source
open source / metrics4s / overview
No. 03now.sendline · Apache-2.0

metrics4s

Metrics for Scala 3

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

The 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 _total or a duration histogram that does not end in _seconds is a compile error.
  • A unit is a type. Histogram.seconds records a FiniteDuration and 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.

scala
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,
)
scala
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.defaultRegistry is 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 a PrometheusRegistry() 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 /metrics never goes out through the public ingress. A port that is taken fails with an ExporterException that names it.
  • Label only from closed sets: an enum, a list known at startup (LabelValue.enumerated), or LabelValue.bounded for a value the deployment fixes, such as the build's version in an Info. A user id, a path or an error message adds a series for every value it ever takes.

Where next

built from README.md at v0.2.0 edit this page on GitHub