Contributing to metrics4s
Thanks for taking the time to contribute. This page covers what you need to build the project, run its tests, where common kinds of changes go, and how a release is cut.
Building
JDK 25 is required, and the build checks it when it loads: on an older JDK it stops with a message naming the
version it found, instead of letting most modules compile and metrics4s-kyo fail with
UnsupportedClassVersionError. Kyo ships Java 25 class files and its Frame macro loads them inside the compiler, so
the whole build follows the module with the highest floor. cs java --jvm 25 or brew install openjdk@25 gets you
one; CI uses the same.
What the build produces runs on JDK 17: -java-output-version 17 writes Java 17 class files and refuses any JDK API
newer than 17, and JavaVersionItSpec checks a class of every published module. Only metrics4s-rapid (21) and
metrics4s-kyo (25) need more, because the libraries they wrap do.
Every published module compiles on Versions.scala3 (3.9, the LTS) in project/Dependencies.scala. it is the
exception on purpose: it compiles on Versions.scala3Next (3.10) against the library compiled by 3.9. The name,
help and bucket checks are inline macros that expand in the caller, so what a user on a newer compiler sees is only
visible from a module that compiler builds. Keep scala3Next on the newest Scala 3 release.
sbt compile # compile everything
sbt "testOnly *" # run the unit tests of every aggregated module
sbt "it/testOnly *" # run the integration tests, needs Docker
sbt "scalafmtCheckAll; scalafmtSbtCheck; it/scalafmtCheck; it/Test/scalafmtCheck" # verify formatting, as CI doessbt 2 takes several tasks as one quoted string separated by ;. It also caches test runs and replays a cached run's
log, so a repeated test with unchanged inputs runs nothing; testOnly * is what CI uses, and changing the filter
forces a real run. scalafmtCheckAll does not cover it/, which is not aggregated, nor the .sbt files, which is why
the format check above names them.
To run one module's tests:
sbt core/test
sbt "kyo/testOnly metrics4s.runtime.kyo.KyoEffectSpec"Publishing credentials live in ~/.sbt/sonatype_credentials, outside the repository. A checkout without the file
builds and tests exactly the same; the build only adds the credentials when the file is there.
Project layout
| Path | What lives there |
|---|---|
core |
definitions, typed labels, instruments, callbacks, Effect/Dispatch/Allocated and the backend SPI; no dependencies |
backend/prometheus |
the backend on the Prometheus Java client 1.x, the exporter, JVM metrics |
runtime/cats, runtime/zio, runtime/kyo, runtime/rapid |
given Effect[F] and given Dispatch[F] per runtime, the adapter to its resource type, its runtime metrics |
testkit |
MetricsTestkit: a real registry read back as typed values, and missingSeries for dashboards |
grafana |
dashboards and README tables generated from the definitions |
integrations/http |
the HTTP server and client metrics shared by integrations/http4s, integrations/tapir and integrations/sttp |
integrations/mongo |
command and pool listeners for the MongoDB driver |
it |
Prometheus, Grafana and MongoDB in Docker via Testcontainers, and the README's examples, compiled |
benchmarks |
JMH benchmarks of the hot path against the raw Prometheus client |
Adding a runtime
A runtime needs a given Effect[F] and a given Dispatch[F]. Effect has seven abstract members: pure, delay,
map, flatMap, raiseError, handleErrorWith and guaranteeCase; everything else has a default and may be
overridden. Dispatch runs an effect from a callback thread, which is what scrape-time observations need. See
runtime/cats for the reference.
The instances are tested by extending EffectSuite from core's test sources, which the runtime modules depend on
through core % "test->test". It asks for four things: how to run an effect, an effect that never ends, a timeout and
the runtime's native way to use an Allocated. A new runtime gets the same coverage as the existing four by passing
it, and where it cannot pass a case, a spec of its own pins down what it does instead, as RapidCancellationSpec does.
guaranteeCase must report cancellation as ExitCase.Canceled. Kyo and rapid do not surface it as such by default:
Kyo closes an abandoned fiber with KyoException("fiber abandoned"), and rapid interrupts a thread, which surfaces as
an InterruptedException. DESIGN.md has the details.
If the runtime exports metrics of its own, write them as definitions in the module, check their names and types
against what services already scrape, and run missingSeries against the dashboard row that reads them, as the
*RuntimeMetricsSpec of each runtime does.
Adding an integration
An integration is a set of definitions plus whatever hooks into the library. Every label comes from a closed domain:
routes, operations and collections are given when the metrics are built, and anything else is counted under an
unmatched or other value. A raw path or a raw collection name never becomes a label value. The hooks record
through the instruments' unsafe side, so they need no effect. Add a dashboard row for the new metrics to Rows in
grafana, and test both through MetricsTestkit with the values a real registry holds.
Tests
- Specs check real values read from a real registry, not only that a series exists.
grafanacompares a rendered dashboard withgrafana/src/test/resources/processor.jsonbyte for byte. A deliberate change to rendering means rewriting that file; the failing spec shows the new JSON.it/needs Docker:prom/prometheus:v3.5.0,grafana/grafana:12.0.0andmongo:8.2. It imports a generated dashboard into Grafana and runs every panel's queries through Grafana's datasource proxy. On Windows the forks point Testcontainers attcp://localhost:2375, so enable Docker Desktop's "Expose daemon on tcp://localhost:2375 without TLS", or setDOCKER_HOSTyourself.- Every Scala block in README.md and RECIPES.md is copied from an example under
it/src/test/scala/metrics4s/it/docs, which theitmodule compiles with the same warnings as the library.DocsItSpecfails on a block that matches no example, whitespace aside, and on a dependency line whose version is not the onebuild.sbtpublishes.RecipesItSpecruns the recipes that do something. Change the example and the block together. - There is no documentation site in this repository. oss.sendline.now/metrics4s is built in its own repository from README.md and the files it links, so link between them with relative paths.
- Numbers in BENCHMARKS.md come from
sbt "benchmarks/Jmh/run"; rerun it when the hot path changes.
Code style
Formatting is enforced by scalafmt (.scalafmt.conf) and checked in CI. Run
sbt scalafmtAll before opening a PR.
The code carries no comments and no scaladoc. Make it type-safe and obvious instead; where the logic is subtle enough to need explaining, the explanation belongs in README.md, DESIGN.md or COMPATIBILITY.md, not in a comment nobody reading the docs will find.
The build turns on more warnings than the compiler does by default: -Wunused:all, -Wvalue-discard,
-WunstableInlineAccessors, -Wshadow:all, -Winfer-union, -Wimplausible-patterns and -Wrecurse-with-default.
Any warning fails the build (-Werror), except under the -source:future check, whose point is to see what a future
compiler will say. One message is silenced: Kyo's Frame macro expands to @nowarn annotations that suppress
nothing, which is Kyo's to fix.
Releasing
-
Set
versioninbuild.sbtto the release and give it its section in CHANGELOG.md, in Added / Changed / Fixed order. Before1.0.0a minor release may break the API; every break gets an entry saying what to change. -
Check that CI on
mainis green, the integration tests included. -
Sign and stage the artifacts, then upload them to Maven Central and release them:
bashrm -rf target/sona-staging sbt "publishSigned; sonaRelease"publishSignedwrites the signed artifacts totarget/sona-staging, which is whatpublishTopoints at;sonaReleaseuploads that bundle with the credentials from~/.sbt/sonatype_credentialsand publishes it. It uploads whatever the directory holds, so clear it first, or an earlier run's artifacts go along. -
Tag the release,
v<version>, and write the GitHub release notes from the changelog section.
From 1.0.0 two more steps apply, and nothing fails when they are missed:
- Move the MiMa baseline.
binaryCompatibleWithinbuild.sbtnames the releases the artifacts are checked against. It staysSet.emptyuntil1.0.0is on Maven Central, and then becomesSet("1.0.0"); otherwise the compatibility promise is asserted and never checked. - Record any deliberate break. A break inside a major version needs a MiMa exclusion in
build.sbtand an entry in COMPATIBILITY.md.
Opening a pull request
- Keep PRs focused: one runtime, one integration or one fix per PR is easier to review than a mix.
- Add or extend tests for anything behavioural you change.
- The format check and
sbt "testOnly *"should pass locally before you push. The integration tests need Docker and are not required for most changes, but CI runs them too.