From nothing
to a scrape.
Ten steps, in the order a service needs them. Every fragment below is the real signature; every output is what this library wrote when the code was run.
- Steps
- ten
- Packages
- metric · expo · push · asgi · multiproc
- Clock
- handed in
Install.
One line. The module has no dependency at runtime except the standard
library; moonasgi is used only by the asgi
package, and moonast only by the generator, and only if you
run it.
moon add moonbitstack/moonp8s
Import the package that owns the type you construct. The root package
re-exports the common names, but a re-exported type arrives without its
constructors — @moonp8s.Registry is visible from the
facade, Registry::new is not.
Say it on the function.
Most metrics count or time one function, so that is where they are
written. The function stays as it is, and a generator adds
place_metered beside it, a metric for each annotation, and
register_metrics.
#p8s.counter("orders_total", help="Orders asked for.")
#p8s.counter("orders_refused_total", help="Orders refused.", on=raised)
#p8s.timed("order_seconds", help="Seconds to place an order.", buckets="0.5, 1, 5")
fn place(item : String) -> Int raise Sold { ... }
let registry = @moonp8s.Registry::new()
register_metrics(registry)
let receipt = place_metered("tea", now=clock)
on is call, done or
raised. A timing is recorded whichever way the call ends, a
cancelled one included. The rest of this page builds the same metrics by
hand, which is what the generated code does.
A registry and a gauge.
A registry is the thing a scrape reads. Everything registered with it is collected in registration order, which is why the output below has the same shape as the code.
let registry = @metric.Registry::new()
let running = registry.gauge(
"jobs_running", "Jobs running now.",
)
running.set(3.0)
let families = registry.collect()
output · the compatibility format
There is no timestamp until the caller gives one. A scrape stamps the samples it serves — see step six — but a fixture collected without a time stays without one.
One metric, many series.
A Family is a metric with a fixed list of label names and
one child per label set. The factory is called once per new set, and it
may refuse: a constructor that validates its arguments cannot be called
from a function that cannot.
let by_queue = @metric.Family::new(
["queue"],
fn() raise @metric.Refused {
@metric.Gauge::new()
},
)
registry.register("jobs_running", "Jobs running now.", by_queue)
by_queue.labels(["email"]).set(2.0)
by_queue.labels(["billing"]).set(1.0)
output
Labels are written sorted by name, not in the order they were passed.
That is what makes two scrapes of the same series compare with a plain
diff.
A histogram, and its edges.
Histogram::new() takes the fourteen bounds the official
clients share. Pass your own when you know what the service is for —
the edges are the answer, so they should be chosen, not inherited.
let latency = registry.histogram(
"request_duration_seconds",
"Request latency.",
)
latency.observe(0.041)
latency.observe(0.317)
// or name the edges: bounds must be finite and increasing
let wide = @metric.Histogram::new(buckets=[0.1, 0.5, 2.0])
let log = @metric.Histogram::new(
buckets=@metric.exponential(0.004, 1.4, 20),
)
An observation that lands above the last bound is counted in
+Inf, never dropped. A histogram therefore always adds up
to its own _count, which is the property a summary does not
have.
Two formats, one snapshot.
Collecting gives an array of families; writing it gives text. The same array can leave as the compatibility format or as OpenMetrics, and the counts cannot disagree because they are the same objects.
let families = registry.collect(at=1_700_000_015.0)
let text = @expo.text(families)
let om = @expo.openmetrics(families)
output · the histogram's last three lines, both formats
The difference is the timestamp: milliseconds in 0.0.4, seconds in 1.0.
The # EOF line is not decoration — a reader uses it to tell
a truncated scrape from a complete one.
The handler.
@asgi.app answers GET /metrics with the text
negotiated from the request's Accept, OPTIONS
with the methods it allows, and everything else with
405. It also answers /favicon.ico with
204, because a browser will ask and a log full of 404s is a
log with the real 404s buried in it.
let metrics = @asgi.app(registry)
// or behind your own router, with counts for every request:
let counted = @asgi.middleware(registry)(handler)
The middleware needs a clock, and takes one: now is a
function returning seconds. Pass the same one the rest of your service
uses and the panel and the histogram agree.
Several processes.
A worker pool multiplies every number by the number of workers. Each
process writes what it has counted to its own file in one directory,
and the process that answers /metrics reads the directory.
Nothing is locked, nothing is shared, and a scrape never waits for a
worker that is busy.
// in each worker, after a batch
@directory.write(dir, pid, registry.collect(),
modes=[("queue_depth", @multiproc.Mode::Sum)],
)
// in the process that answers the scrape
let families = @directory.collect(dir)
@expo.text(families)
Counters, histograms and summaries add up across processes. A gauge
does not, so it has to be told what to do: Mode::Sum adds
the processes' values, Min and Max take an
edge, MostRecent keeps the value written last, and the
default, Mode::All, keeps the processes apart with a
pid label on each series.
A flush is idempotent: the files hold totals since the process started, not deltas, so flushing twice is flushing once. Each file is written beside its final name and renamed into place, so a scrape reads a whole file or the previous one.
Rate is arithmetic, not an API.
Nothing in this library computes a rate. A counter is a number that only goes up; the slope is the reader's business, and doing it at the scrape is what makes the result independent of when the panel asks.
let first = requests.get()
// ... one window later ...
let per_second = (requests.get() - first) / 15.0
The one thing the library does do is refuse a counter that goes down:
inc(by=-1.0) raises Refused instead of
quietly becoming a gauge. A reset is a reset, and
Counter::reset says so, with a new creation time if you
give one.
What it refuses.
Every refusal is one error, Refused, carrying a sentence
with the offending name or bound in it. The point is that a mistake
surfaces at the call that made it, not in a dashboard three weeks later.
| Refused | Because |
|---|---|
| a name | must match [a-zA-Z_:][a-zA-Z0-9_:]* |
| a label name | must match [a-zA-Z_][a-zA-Z0-9_]* |
| the same name twice | the registry already spent it |
| a bucket bound | finite, and strictly increasing |
| a counter step | cannot be negative |
| an exemplar label | 128 characters, at most |
| a unit | must be a bare word, not a sentence |
What is not here.
Three things a Prometheus client in another language does that this one does not, and one it does differently.
- No protobuf. The two text formats are the surface. A binary exposition is a different transport, and it is not promised here.
- No multiprocess directory on wasm. The merge is portable, but reading and writing a worker's file is a native package: the browser has no directory to put it in.
-
No process metrics read for you.
ProcessCollectorexports the seven series, but the caller supplies the numbers: this library does no I/O, which is what keeps it portable to wasm. -
No quantiles from a histogram. A histogram answers with an
edge. If you need an estimated quantile, use a
Summary, which keeps a sketch.
Everything listed here is a decision, not an omission: each one would add a dependency, a clock, or a promise this library does not want to keep on every target.