moonp8s Guide · install to scrape Guide GitHub
Guide

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
01

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.

02

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.

03

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

# HELP jobs_running Jobs running now. # TYPE jobs_running gauge jobs_running 3.0

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.

04

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

jobs_running{queue="email"} 2.0 jobs_running{queue="billing"} 1.0

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.

05

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.

06

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

# 0.0.4 request_duration_seconds_bucket{le="+Inf"} 2.0 1700000015000 request_duration_seconds_count 2.0 1700000015000 request_duration_seconds_sum 0.358 1700000015000 # 1.0 request_duration_seconds_bucket{le="+Inf"} 2.0 1.700000015e+09 request_duration_seconds_count 2.0 1.700000015e+09 request_duration_seconds_sum 0.358 1.700000015e+09 # EOF

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.

07

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.

08

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.

09

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.

10

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.

RefusedBecause
a namemust match [a-zA-Z_:][a-zA-Z0-9_:]*
a label namemust match [a-zA-Z_][a-zA-Z0-9_]*
the same name twicethe registry already spent it
a bucket boundfinite, and strictly increasing
a counter stepcannot be negative
an exemplar label128 characters, at most
a unitmust be a bare word, not a sentence
11

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. ProcessCollector exports 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.