A fixed-layout, allocation-free histogram backed by an :counters array.
Percentiles are the one measurement you cannot compute from a plain counter, and computing them the obvious way — keeping the samples — costs an allocation on every event. Kepler instead keeps a bucketed distribution: a value is mapped to a bucket with a handful of integer operations, and recording it is a single atomic increment.
Layout
Buckets are log-linear, the same scheme HdrHistogram uses. Values below 16 land in their own exact bucket; above that, each power of two is split into 16 sub-buckets. That bounds the relative error at 1/16 — under 6.25% — across the whole range, while needing only 960 slots to cover every positive 64-bit integer.
A bucket's reported value is its upper bound, so "p99 = 2431" reads as "99% of samples were at or below 2431" — never an undershoot, which is the semantic you want when the number is feeding a threshold.
Reading
Counters only ever increase, so a tick reads the whole array and subtracts
the previous read. delta/2 does that, and percentile/3 interpolates over
the result.
iex> ref = :counters.new(Kepler.Histogram.slots(), [:write_concurrency])
iex> Enum.each([100, 120, 5_000], &Kepler.Histogram.record(ref, &1))
iex> {counts, total} = Kepler.Histogram.delta(Kepler.Histogram.snapshot(ref), Kepler.Histogram.zeros())
iex> total
3
iex> Kepler.Histogram.percentile(counts, total, 50) in 120..128
true
Summary
Functions
The 1-based :counters index that value belongs to.
The largest value that maps to index.
Per-bucket difference between two snapshots, plus the total sample count.
The pth percentile of counts, or nil when total is zero.
Records value into ref.
Number of :counters slots a histogram needs.
Reads every bucket out of ref.
Types
@type counts() :: [non_neg_integer()]
Per-bucket counts, index 1 of the array first.
Functions
@spec bucket_index(non_neg_integer()) :: pos_integer()
The 1-based :counters index that value belongs to.
Exact for 0..15; log-linear above that.
@spec bucket_upper(pos_integer()) :: non_neg_integer()
The largest value that maps to index.
This is what percentile queries report, so a reported value never undershoots the true sample.
@spec delta(counts(), counts()) :: {counts(), non_neg_integer()}
Per-bucket difference between two snapshots, plus the total sample count.
Counters only grow, so this is what turns a monotonic array into "what happened during the last tick".
@spec percentile(counts(), non_neg_integer(), number()) :: non_neg_integer() | nil
The pth percentile of counts, or nil when total is zero.
p is a number in 0..100. The result is the upper bound of the bucket the
percentile falls in.
@spec record(:counters.counters_ref(), term()) :: :ok
Records value into ref.
Runs on the caller's process — which is the process that emitted the
telemetry event — so it does exactly one atomic increment and nothing else.
Non-numeric and negative values are ignored rather than raised, because a
raising telemetry handler is detached by :telemetry and takes the host's
emitting process down with it on the way.
@spec slots() :: pos_integer()
Number of :counters slots a histogram needs.
Allocate the array with :counters.new(Kepler.Histogram.slots(), [:write_concurrency]).
@spec snapshot(:counters.counters_ref()) :: counts()
Reads every bucket out of ref.
@spec zeros() :: counts()
A zeroed baseline, for the first delta/2 after installation.