AudioProxy.Metrics.Exposition (audio_proxy v0.4.0)

Copy Markdown View Source

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

definition()

@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.

observation()

@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.

sample()

@type sample() :: {[String.t()], number()}

A counter or gauge series: label values in definition order, and a number.

Functions

content_type()

@spec content_type() :: String.t()

The Content-Type a scrape response must carry.

render(definitions, series)

@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.