moonp8s Prometheus metrics · two text formats · MoonBit Prometheus · MoonBit GitHub

Counted, not
remembered.

moonp8s is Prometheus metrics for MoonBit. Fifteen seconds of samples become one field of counters — and the two text formats a scrape can read.

rest the pointer on a cell
— samples — s window — s⁻¹ — over budget — s p90
02 / 06

Everything a scrape reads is one line.

A name, its labels in braces, a number and the instant it was read. The rest of this page is that line, arranged. The specimen below was written by the encoder in this repository — copied, not typed.

—— — —

metricthe family, plus a suffix
labelsthe bound, sorted
valueat or below 0.1 s
timestampmilliseconds in 0.0.4

written by this library, not typed here

text/plain; version=0.0.4; charset=utf-8

Two formats, one model. The compatibility format writes milliseconds and stops there. OpenMetrics writes seconds, a # UNIT line, exemplars and a closing # EOF. Both come out of the same collected families, so a switch between them cannot change a count.

The number above is cumulative on purpose. A histogram is not a bucket count but a budget: le="0.1" says how many samples took 100 milliseconds or less, so a reader never has to add steps to answer the question it came with.

03 / 06

The edges decide the answer.

The same 1,027 samples, cut three ways. The count, the sum and the rate do not move — the median does, because a histogram answers with the edge it was given. Narrower steps are not a better answer; they are a more expensive question.

samples
—
sum
—
median
—
p90
—

the count is the same in all three

04 / 06

Three things this library will not decide for you.

When a scrape happens, what is allowed to take a name, and which format leaves the process. All three are handed in, which is why the same code serves a server, a replay of last night, and a test that needs the bytes to be the same twice.

The clock

A collection is stamped with the moment the caller says it happened. Nothing here reads a clock, so a window replayed from a fixture carries the fixture's time, and two runs of a test compare equal.

let families = registry.collect(
  at=1_700_000_015.0,
)
// samples without a time of their own
// are stamped with it; a backfilled
// sample keeps the date it was given.

The names

A registry is the only place a name is spent. Register twice and the second attempt is refused with the name it would have collided with, rather than the two quietly merging into one series.

registry.register(
  "jobs", "Jobs seen.", counter,
)
// Refused: "metric already
// registered: jobs"

The format

The encoder is chosen at the edge, from the request's own Accept. One collected snapshot can leave as the compatibility text or as OpenMetrics; the counts are the same object.

@expo.negotiate(accept)
// Text004 · OpenMetrics100

@expo.text(families)
@expo.openmetrics(families)
// same counts, different edges
05 / 06

Four processes, four files, one answer.

A pool of workers each counts what it saw. No worker reads another's memory, and the scrape does not wait for any of them: it reads the directory and adds up what is there. Everything the merge has to decide is decided by a name — of a file, of a mode.

The file

A worker writes its totals where the scrape can find them, one file per kind, named after its process. A file is written beside its final name and renamed into place, so a scrape never reads half of one.

@directory.write(
  dir, pid, registry.collect(),
)
// counter_7.db · histogram_7.db
// gauge_sum_7.db · summary_7.db

The mode

A counter adds up, and so does a bucket. A gauge does not: three workers' queue depths are three answers to one question, and which answer is wanted is the caller's to say — the scrape cannot know.

@directory.write(
  dir, pid, families,
  modes=[("queue_depth", @multiproc.Mode::Sum)],
)
// all · min · max · sum · mostrecent
// each with a live spelling beside it

The last one out

A live gauge describes a process that is running now, so when the supervisor says the process is gone, the file goes with it. A counter stays: what the process counted is still true after it exits.

@directory.mark_dead(dir, pid)
// gauge_livesum_7.db removed
// counter_7.db and its totals stay
06 / 06

The index.

Seven packages and a facade. Import the package that owns the type you construct: a re-exported type arrives without its constructors. The complete reference, down to the last derived method, is on the API plate.

metric — the model, the counters, the registry

Counter::new(created?) → Counter
Counter::inc(by?, exemplar?) → Unit raise
Counter::shard() → LocalCounter · accumulate, then flush
Gauge::set, inc, dec, set_function(v, timestamp?) → Unit
Histogram::new(buckets?, created?) → Histogram raise
Histogram::observe(v, exemplar?) → Unit
Summary::new(quantiles~, created?) → Summary raise
Info::new, StateSet::exclusivethe two kinds that are not numbers
Family::new(labels, make) → Family[M] raise
Family::labels(Array[String]) → M raise · one child per label set
Registry::new(prefix?, labels?) → Registry raise
Registry::register(name, help, &Metric, unit?) → Unit raise
Registry::collect(at?) → Array[MetricFamily] · the scrape
Registry::sub, adda child registry · a collector worth calling
ProcessCollector, ProcessStatsthe seven process series, handed in
buckets, linear, exponentialthe shared edges, and two generators
Refusedone message, always about a name or a bound

expo — the two text formats, and their reader

text(Array[MetricFamily]) → String
openmetrics(Array[MetricFamily]) → String
negotiate(Accept) → Format
Format::content_type() → String · the header to answer with
parse(String, format?) → Array[MetricFamily] raise
format_float, escape_label_value, escape_helpthe three spellings that must match a scraper

push — the gateway request, built not sent

push, push_add(url, job, families, grouping_key?, format?) → PushRequest
delete(url, job, grouping_key?) → PushRequest

asgi — the handler and the middleware

app(Registry) → (Request) → Response
middleware(Registry, now?) → a wrapper for a handler

multiproc — several processes, one set of numbers

Modeall · liveall · min · max · sum · mostrecent, and their live forms
merge(Array[Part]) → Array[MetricFamily] · what a scrape does
Part, Entryone worker's file, one value in it
directory.write(dir, pid, families, modes?) → Written · a worker's flush
directory.collect(dir, at?) → Array[MetricFamily] · the scrape, native only
directory.mark_dead(dir, pid) → Int · the live gauges of a process that exited

compat — the surface an older caller already has

CounterVecadd, inc, value, total, labels — one label, one counter
ServerMetricsnew, requests, latency, register(prefix?)
RemoteWriternew(endpoint), request(StatReport) → RemoteRequest