Pure Standard ML implementation of Prometheus-style metrics — counters, gauges and histograms with the text exposition format — and zero dependencies. Builds and tests cleanly under both MLton and Poly/ML, producing byte-identical output.
Prometheus scrapes metrics from targets in a simple line-oriented text format. This library lets an SML program build that output:
- Counter — a monotonically increasing integer (
inc,incBy,value). A negativeincByraisesNonMonotonic. - Gauge — a real value that can go up or down (
set,inc,dec,incBy,decBy,value). - Histogram — observations bucketed by configurable upper bounds (
le), with cumulative_bucketcounts (per Prometheus convention), an implicit+Infbucket, plus_sumand_count. - Registry — collects metric families and renders the exposition format
via
Registry.expose.
Each metric is a family with a name, help text and zero or more child series keyed by label name/value pairs, so one family can expose many series (for example one per HTTP status code).
The renderer follows the Prometheus text format exactly:
# HELP <name> <help text>
# TYPE <name> <counter|gauge|histogram>
<name>{label="v",...} <value>
- One
# HELPand# TYPEline per family, followed by all of its series. - Families are emitted in registration order; the label pairs of a series are emitted in the order they were supplied (a series' identity is independent of pair order).
- Label values escape
\,"and newline; help text escapes\and newline. - Histograms emit cumulative
_bucket{le="..."}lines (includingle="+Inf"), then_sumand_count.
Counter values and histogram counts are integers. All real-valued numbers
(gauge values, histogram sums and bucket bounds) are rendered with a forced
decimal point and a leading - for negatives, so output is byte-identical
across MLton and Poly/ML. As a result an integer bucket bound of 1 renders as
le="1.0", and a gauge of 21.5 renders as 21.5.
A small, readable, dependency-free metrics library is handy for instrumenting SML programs that are scraped by Prometheus, for teaching the exposition format, and as a reference for the counter/gauge/histogram model. The output is deterministic and identical under both compilers, which makes it trivial to golden-test.
Using smlpkg:
smlpkg add github.com/sjqtentacles/sml-metrics
smlpkg syncThen add the library to your MLB file:
$(SML_LIB)/basis/basis.mlb
lib/github.com/sjqtentacles/sml-metrics/sources.mlb
open Metrics
val requests =
Counter.new {name = "http_requests_total",
help = "The total number of HTTP requests."}
val () = Counter.incBy requests [("method", "post"), ("code", "200")] 1027
val () = Counter.incBy requests [("method", "post"), ("code", "400")] 3
val temperature =
Gauge.new {name = "temperature_celsius",
help = "Current temperature in Celsius."}
val () = Gauge.set temperature [] 21.5
val latency =
Histogram.new {name = "request_latency_seconds",
help = "Request latency in seconds.",
buckets = [0.1, 0.5, 1.0]}
val () = List.app (Histogram.observe latency [])
[0.05, 0.3, 0.5, 0.7, 1.0, 2.0]
val reg = Registry.new ()
val () = Registry.counter reg requests
val () = Registry.gauge reg temperature
val () = Registry.histogram reg latency
val () = print (Registry.expose reg)The signature exposes:
signature METRICS =
sig
type labels = (string * string) list
exception NonMonotonic
structure Counter :
sig
type t
val new : {name : string, help : string} -> t
val inc : t -> labels -> unit
val incBy : t -> labels -> int -> unit
val value : t -> labels -> int
end
structure Gauge :
sig
type t
val new : {name : string, help : string} -> t
val set : t -> labels -> real -> unit
val inc : t -> labels -> unit
val dec : t -> labels -> unit
val incBy : t -> labels -> real -> unit
val decBy : t -> labels -> real -> unit
val value : t -> labels -> real
end
structure Histogram :
sig
type t
val new : {name : string, help : string, buckets : real list} -> t
val observe : t -> labels -> real -> unit
val count : t -> labels -> int
val sum : t -> labels -> real
val bucketCounts : t -> labels -> (real * int) list
end
structure Registry :
sig
type t
val new : unit -> t
val counter : t -> Counter.t -> unit
val gauge : t -> Gauge.t -> unit
val histogram : t -> Histogram.t -> unit
val expose : t -> string
end
endRunning make example prints:
# HELP http_requests_total The total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="post",code="200"} 1027
http_requests_total{method="post",code="400"} 3
# HELP temperature_celsius Current temperature in Celsius.
# TYPE temperature_celsius gauge
temperature_celsius 21.5
# HELP request_latency_seconds Request latency in seconds.
# TYPE request_latency_seconds histogram
request_latency_seconds_bucket{le="0.1"} 1
request_latency_seconds_bucket{le="0.5"} 3
request_latency_seconds_bucket{le="1.0"} 5
request_latency_seconds_bucket{le="+Inf"} 6
request_latency_seconds_sum 4.55
request_latency_seconds_count 6
The test suite is written strict-TDD (golden exposition vectors first, then the
implementation). It covers counter monotonicity and per-label series, gauge
inc/dec/set, histogram bucket boundaries (le is inclusive — a value equal to a
bound falls into that bucket) and cumulative counts, label-value and help-text
escaping, and full Registry.expose golden output.
make test # MLton
make test-poly # Poly/MLBoth compilers report 30 passed, 0 failed, with byte-identical output.
MIT.