Kepler.Histogram (Kepler v0.1.0)

Copy Markdown View Source

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

Types

Per-bucket counts, index 1 of the array first.

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.

A zeroed baseline, for the first delta/2 after installation.

Types

counts()

@type counts() :: [non_neg_integer()]

Per-bucket counts, index 1 of the array first.

Functions

bucket_index(value)

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

bucket_upper(index)

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

delta(now, previous)

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

percentile(counts, total, p)

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

record(ref, value)

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

slots()

@spec slots() :: pos_integer()

Number of :counters slots a histogram needs.

Allocate the array with :counters.new(Kepler.Histogram.slots(), [:write_concurrency]).

snapshot(ref)

@spec snapshot(:counters.counters_ref()) :: counts()

Reads every bucket out of ref.

zeros()

@spec zeros() :: counts()

A zeroed baseline, for the first delta/2 after installation.