The Prometheus text exposition format (version 0.0.4), as string assembly.
AudioProxy.Metrics decides what the numbers are; this decides what they
look like on the wire. The split is worth the extra module because the format
has invariants a scraper enforces and a renderer can silently break — a
histogram whose +Inf bucket disagrees with its _count is accepted by
nothing and diagnosed by nobody — and those invariants are testable here
against a hand-written series, with no telemetry, no ETS and no clock in the
way.
What the format requires, and what this does about it
Every metric gets a # HELP and a # TYPE line, emitted whether or not it
has samples: a scraper reading a fresh process should learn the metric exists
and is a counter rather than infer both from the first sample to appear.
Label values are escaped (\, ", newline) and so is help text (\,
newline). Nothing in this application puts a quote in a label — every label
value is a bounded enum — but escaping is one line and the alternative is a
malformed scrape the day something does.
Histograms are the part with real rules. Buckets are cumulative and emitted
in ascending le order, +Inf last and equal to _count, with _sum in
the metric's own unit. AudioProxy.Metrics counts observations into a single
bucket each and the accumulation happens here, so a concurrent observation
costs one :ets.update_counter/4 rather than one per bucket edge.
Determinism
Series are sorted by their label values, so two scrapes of the same state produce byte-identical output. Prometheus does not require it; tests that assert on exact exposition do, and an operator diffing two scrapes by hand gets it for free.
Summary
Types
One metric, as this module needs it.
A histogram series: label values, per-bucket observation counts in the
definition's bucket order (one element longer than buckets, the last being
the +Inf overflow), and the sum of the observations.
A counter or gauge series: label values in definition order, and a number.
Functions
The Content-Type a scrape response must carry.
Renders definitions against series, a map of metric name to samples.
Types
@type definition() :: %{ :name => String.t(), :type => :counter | :gauge | :histogram, :help => String.t(), optional(:labels) => [atom()], optional(:buckets) => [float()], optional(any()) => any() }
One metric, as this module needs it.
labels names the label keys in the order they are emitted; help is one
sentence without a trailing newline.
@type observation() :: {[String.t()], %{buckets: [non_neg_integer()], sum: number()}}
A histogram series: label values, per-bucket observation counts in the
definition's bucket order (one element longer than buckets, the last being
the +Inf overflow), and the sum of the observations.
A counter or gauge series: label values in definition order, and a number.
Functions
@spec content_type() :: String.t()
The Content-Type a scrape response must carry.
@spec render([definition()], %{optional(String.t()) => [sample()] | [observation()]}) :: iodata()
Renders definitions against series, a map of metric name to samples.
A definition with no entry in series — or an empty one — still gets its
# HELP and # TYPE lines.