metrics: add counters, gauges and histograms with Prometheus exposition (#30387)

* metrics: add counters, gauges and histograms with Prometheus exposition (fixes #30331)

* metrics: make series identities and concurrent exposition correct

* metrics: reject colliding histogram names and test invalid inputs

* metrics: wrap module documentation

* metrics: respect markdown line length

---------

Co-authored-by: Alexander Medvednikov <alexander@medvednikov.com>
This commit is contained in:
Mahdi ramezaan zaadeandAlexander Medvednikov authored and GitHub committed 2026-10-14 14:06:36 +03:00
1 parent a5356a0e68
commit cd8a030bba
3 files changed
+558

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
`metrics.new_registry()` owns named counters, gauges, and histograms. Create instruments with
`counter(name)`, `gauge(name)`, or `histogram(name, bounds)`. `Counter.add` accepts nonnegative
increments; gauges support `set` and signed `inc`. Invalid names, negative or NaN counter
increments, conflicting instrument types or histogram sample names, and invalid histogram bounds
panic. Names use ASCII letters, underscores, and noninitial digits; metric names may also
contain colons.
Every operation, including registration and `to_prometheus`, synchronizes access. A scrape locks
each instrument while rendering it; values from different instruments are not a global snapshot.
Caller-owned label maps must not be modified concurrently with an operation that reads them.
Label order is irrelevant, and labels containing commas, equals signs, quotes, backslashes, or
newlines remain distinct and are escaped for Prometheus exposition.
Histograms use finite, strictly increasing bounds. Reusing a name requires identical bounds.
`record` maintains cumulative buckets, count, and sum. The exporter appends an implicit `+Inf`
bucket; the `le` label is reserved and cannot be supplied to `record`. `to_prometheus()` renders
HELP/TYPE lines and samples in Prometheus text format, including `NaN`, `+Inf`, and `-Inf` gauge
values. Construct instruments through the registry so they are exported.
+341
View File
@@ -0,0 +1,341 @@
module metrics
import sync
import math
// Labels are a metric's dimension set, e.g. {'route': '/api/users'}.
// label_key renders a canonical encoding so equal label sets share a series.
fn label_key(labels map[string]string) string {
mut keys := labels.keys()
keys.sort()
mut out := []string{}
for k in keys {
assert valid_name(k, false), 'metrics: invalid label name ${k}'
out << '${k}="${escape_label(labels[k])}"'
}
return out.join(',')
}
// SeriesId names one labeled series of an instrument.
struct SeriesId {
name string
labels string
}
fn series_map_key(id SeriesId) string {
return id.name + '\x00' + id.labels
}
// Counter is a monotonically increasing cumulative counter.
@[heap]
pub struct Counter {
mut:
mutex &sync.Mutex = sync.new_mutex()
vals map[string]f64
}
// Gauge is a point-in-time value that goes up and down.
@[heap]
pub struct Gauge {
mut:
mutex &sync.Mutex = sync.new_mutex()
vals map[string]f64
}
// Histogram accumulates observations into fixed buckets plus sum and count,
// in the Prometheus exposition model.
@[heap]
pub struct Histogram {
mut:
mutex &sync.Mutex = sync.new_mutex()
bounds []f64
buckets map[string][]u64
sums map[string]f64
counts map[string]u64
}
// Registry owns the instruments; create instruments through it so names stay
// namespaced in one place.
@[heap]
pub struct Registry {
mut:
mutex &sync.Mutex = sync.new_mutex()
counters map[string]&Counter
gauges map[string]&Gauge
histograms map[string]&Histogram
}
// new_registry creates an empty Registry.
pub fn new_registry() &Registry {
return &Registry{}
}
// counter returns the named counter, creating it on first use.
pub fn (mut r Registry) counter(name string) &Counter {
assert valid_name(name, true), 'metrics: invalid instrument name ${name}'
r.mutex.lock()
defer { r.mutex.unlock() }
for existing in r.histograms.keys() {
assert name !in [existing + '_bucket', existing + '_sum', existing + '_count'], 'metrics: instrument name conflicts with histogram samples'
}
assert name !in r.gauges, 'metrics: instrument name already used'
assert name !in r.histograms, 'metrics: instrument name already used'
if name !in r.counters {
r.counters[name] = &Counter{
mutex: sync.new_mutex()
}
}
return r.counters[name] or { panic('metrics: missing counter') }
}
// gauge returns the named gauge, creating it on first use.
pub fn (mut r Registry) gauge(name string) &Gauge {
assert valid_name(name, true), 'metrics: invalid instrument name ${name}'
r.mutex.lock()
defer { r.mutex.unlock() }
for existing in r.histograms.keys() {
assert name !in [existing + '_bucket', existing + '_sum', existing + '_count'], 'metrics: instrument name conflicts with histogram samples'
}
assert name !in r.counters, 'metrics: instrument name already used'
assert name !in r.histograms, 'metrics: instrument name already used'
if name !in r.gauges {
r.gauges[name] = &Gauge{
mutex: sync.new_mutex()
}
}
return r.gauges[name] or { panic('metrics: missing gauge') }
}
// histogram returns the named histogram with the given bucket upper bounds
// (must be finite and strictly ascending), creating it on first use.
// Reusing a name requires the same bounds. Invalid configuration panics.
pub fn (mut r Registry) histogram(name string, bounds []f64) &Histogram {
assert valid_name(name, true), 'metrics: invalid instrument name ${name}'
r.mutex.lock()
defer { r.mutex.unlock() }
for existing in r.histograms.keys() {
assert name !in [existing + '_bucket', existing + '_sum', existing + '_count'], 'metrics: instrument name conflicts with histogram samples'
}
assert name !in r.counters, 'metrics: instrument name already used'
assert name !in r.gauges, 'metrics: instrument name already used'
for suffix in ['_bucket', '_sum', '_count'] {
sample := name + suffix
assert sample !in r.counters && sample !in r.gauges && sample !in r.histograms, 'metrics: histogram sample name already used'
}
for i, bound in bounds {
assert !math.is_nan(bound) && !math.is_inf(bound, 0), 'metrics: bucket bounds must be finite'
assert i == 0 || bound > bounds[i - 1], 'metrics: bucket bounds must be strictly ascending'
}
if existing := r.histograms[name] {
assert existing.bounds == bounds, 'metrics: bucket bounds differ'
}
if name !in r.histograms {
r.histograms[name] = &Histogram{
mutex: sync.new_mutex()
bounds: bounds.clone()
}
}
return r.histograms[name] or { panic('metrics: missing histogram') }
}
// add increments the labeled series by v. Negative or NaN increments panic;
// use a gauge for values that decrease.
pub fn (mut c Counter) add(v f64, labels map[string]string) {
assert v >= 0, 'metrics: counter increments must be nonnegative'
k := series_map_key(SeriesId{'', label_key(labels)})
c.mutex.lock()
defer {
c.mutex.unlock()
}
c.vals[k] = c.vals[k] + v
}
// get returns the current value of the labeled series (0 if absent).
pub fn (c &Counter) get(labels map[string]string) f64 {
k := series_map_key(SeriesId{'', label_key(labels)})
c.mutex.lock()
defer {
c.mutex.unlock()
}
return c.vals[k]
}
// set assigns the labeled series.
pub fn (mut g Gauge) set(v f64, labels map[string]string) {
k := series_map_key(SeriesId{'', label_key(labels)})
g.mutex.lock()
defer {
g.mutex.unlock()
}
g.vals[k] = v
}
// inc adds v to the labeled series.
pub fn (mut g Gauge) inc(v f64, labels map[string]string) {
k := series_map_key(SeriesId{'', label_key(labels)})
g.mutex.lock()
defer {
g.mutex.unlock()
}
g.vals[k] = g.vals[k] + v
}
// get returns the current value of the labeled series (0 if absent).
pub fn (g &Gauge) get(labels map[string]string) f64 {
k := series_map_key(SeriesId{'', label_key(labels)})
g.mutex.lock()
defer {
g.mutex.unlock()
}
return g.vals[k]
}
// record adds one observation to the labeled series' buckets, sum and count.
pub fn (mut h Histogram) record(v f64, labels map[string]string) {
assert 'le' !in labels, 'metrics: histogram label le is reserved'
k := series_map_key(SeriesId{'', label_key(labels)})
h.mutex.lock()
defer {
h.mutex.unlock()
}
mut counts := h.buckets[k]
if counts.len == 0 {
counts = []u64{len: h.bounds.len + 1}
}
for i, b in h.bounds {
if v <= b {
counts[i]++
}
}
counts[counts.len - 1]++ // +Inf bucket
h.buckets[k] = counts
h.sums[k] = h.sums[k] + v
h.counts[k] = h.counts[k] + 1
}
// bucket_counts returns the cumulative per-bucket counts of the labeled
// series, plus one trailing +Inf entry.
pub fn (h &Histogram) bucket_counts(labels map[string]string) []u64 {
k := series_map_key(SeriesId{'', label_key(labels)})
h.mutex.lock()
defer {
h.mutex.unlock()
}
return h.buckets[k].clone()
}
// sum returns the sum of observations of the labeled series.
pub fn (h &Histogram) sum(labels map[string]string) f64 {
k := series_map_key(SeriesId{'', label_key(labels)})
h.mutex.lock()
defer {
h.mutex.unlock()
}
return h.sums[k]
}
// count returns the number of observations of the labeled series.
pub fn (h &Histogram) count(labels map[string]string) u64 {
k := series_map_key(SeriesId{'', label_key(labels)})
h.mutex.lock()
defer {
h.mutex.unlock()
}
return h.counts[k]
}
// to_prometheus renders all series in Prometheus text exposition format:
// one HELP/TYPE header pair per instrument plus one line per labeled series.
pub fn (mut r Registry) to_prometheus() string {
r.mutex.lock()
defer { r.mutex.unlock() }
mut out := []string{}
for name in r.counters.keys() {
mut c := r.counters[name] or { continue }
out << '# HELP ${name} counter'
out << '# TYPE ${name} counter'
c.mutex.lock()
out << r.render_samples(name, c.vals)
c.mutex.unlock()
}
for name in r.gauges.keys() {
mut g := r.gauges[name] or { continue }
out << '# HELP ${name} gauge'
out << '# TYPE ${name} gauge'
g.mutex.lock()
out << r.render_samples(name, g.vals)
g.mutex.unlock()
}
for name in r.histograms.keys() {
mut h := r.histograms[name] or { continue }
out << '# HELP ${name} histogram'
out << '# TYPE ${name} histogram'
h.mutex.lock()
keys := h.buckets.keys()
for k in keys {
lbl := render_labels(k)
prefix := if lbl == '' { '' } else { lbl + ',' }
counts := h.buckets[k]
for i, b in h.bounds {
out << '${name}_bucket{${prefix}${bucket_le(b)}} ${counts[i]}'
}
out << '${name}_bucket{${prefix}${bucket_le_inf()}} ${counts[counts.len - 1]}'
out << '${name}_sum{${lbl}} ${sample_value(h.sums[k])}'
out << '${name}_count{${lbl}} ${h.counts[k]}'
}
h.mutex.unlock()
}
return out.join('\n') + '\n'
}
// render_samples renders counter/gauge series lines.
fn (mut r Registry) render_samples(name string, vals map[string]f64) string {
mut out := []string{}
for k, v in vals {
lbl := render_labels(k)
if lbl == '' {
out << '${name} ${sample_value(v)}'
} else {
out << '${name}{${lbl}} ${sample_value(v)}'
}
}
return out.join('\n')
}
// render_labels extracts canonical, already escaped `k="v"` pairs.
fn render_labels(k string) string {
return k.all_after('\x00')
}
fn escape_label(value string) string {
return value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
}
fn valid_name(name string, metric bool) bool {
if name == '' { return false }
for i, ch in name {
if (ch >= `a` && ch <= `z`) || (ch >= `A` && ch <= `Z`) || ch == `_`
|| (metric && ch == `:`) || (i > 0 && ch >= `0` && ch <= `9`) {
continue
}
return false
}
return true
}
fn sample_value(value f64) string {
if math.is_nan(value) { return 'NaN' }
if math.is_inf(value, 1) { return '+Inf' }
if math.is_inf(value, -1) { return '-Inf' }
return value.str()
}
// bucket_le renders the le="bound" label fragment.
fn bucket_le(b f64) string {
return 'le="${b}"'
}
// bucket_le_inf renders the le="+Inf" label fragment.
fn bucket_le_inf() string {
return 'le="+Inf"'
}
+199
View File
@@ -0,0 +1,199 @@
module metrics
import math
import os
fn test_counter_aggregation() {
mut r := new_registry()
mut c := r.counter('http_requests_total')
c.add(1.0, {
'route': '/api/users'
})
c.add(2.0, {
'route': '/api/users'
})
c.add(1.0, {
'route': '/health'
})
assert c.get({
'route': '/api/users'
}) == 3.0
assert c.get({
'route': '/health'
}) == 1.0
assert c.get({
'route': '/missing'
}) == 0.0
// key order must not matter
assert c.get({
'route': '/api/users'
'extra': 'x'
}) == 0.0
}
fn test_gauge_levels() {
mut r := new_registry()
mut g := r.gauge('queue_depth')
g.set(5.0, {})
g.inc(-2.0, {})
assert g.get({}) == 3.0
g.inc(10.0, {
'queue': 'a'
})
assert g.get({
'queue': 'a'
}) == 10.0
assert g.get({}) == 3.0, 'label sets must stay separate'
}
fn test_histogram_buckets() {
mut r := new_registry()
mut h := r.histogram('http_latency_ms', [5.0, 10.0, 50.0])
h.record(3.0, {
'route': '/x'
})
h.record(7.0, {
'route': '/x'
})
h.record(100.0, {
'route': '/x'
})
assert h.bucket_counts({
'route': '/x'
}) == [u64(1), 2, 2, 3], 'cumulative buckets plus +Inf'
assert h.sum({
'route': '/x'
}) == 110.0
assert h.count({
'route': '/x'
}) == 3
}
fn test_prometheus_exposition() {
mut r := new_registry()
mut c := r.counter('reqs')
c.add(2.0, {
'route': '/a'
})
mut g := r.gauge('temp')
g.set(21.5, {})
out := r.to_prometheus()
assert out.contains('# TYPE reqs counter')
assert out.contains('reqs{route="/a"} 2')
assert out.contains('# TYPE temp gauge')
assert out.contains('temp 21.5')
mut h := r.histogram('lat', [10.0])
h.record(5.0, {})
prom := r.to_prometheus()
assert prom.contains('lat_bucket{le="10.0"} 1')
assert prom.contains('lat_bucket{le="+Inf"} 1')
assert prom.contains('lat_sum{} 5')
assert prom.contains('lat_count{} 1')
}
fn test_labels_are_unambiguous_and_escaped() {
mut r := new_registry()
mut c := r.counter('requests')
c.add(3, {
'a': 'x,b=y'
})
c.add(4, {
'a': 'x'
'b': 'y'
})
assert c.get({
'a': 'x,b=y'
}) == 3
assert c.get({
'b': 'y'
'a': 'x'
}) == 4
mut g := r.gauge('escaped')
g.set(1, {
'route': 'line\n"quoted"\\end'
})
out := r.to_prometheus()
assert out.contains('requests{a="x,b=y"} 3')
assert out.contains('requests{a="x",b="y"} 4')
assert out.contains(r'escaped{route="line\n\"quoted\"\\end"} 1')
}
fn test_labeled_histogram_exposition_has_bucket_separator() {
mut r := new_registry()
mut h := r.histogram('latency', [10.0])
h.record(3, {
'route': '/x'
})
out := r.to_prometheus()
assert out.contains('latency_bucket{route="/x",le="10.0"} 1')
assert out.contains('latency_bucket{route="/x",le="+Inf"} 1')
assert out.contains('latency_sum{route="/x"} 3')
}
fn test_prometheus_special_values() {
mut r := new_registry()
mut g := r.gauge('special')
g.set(math.inf(1), {
'kind': 'positive'
})
g.set(math.inf(-1), {
'kind': 'negative'
})
g.set(math.nan(), {
'kind': 'nan'
})
out := r.to_prometheus()
assert out.contains('special{kind="positive"} +Inf')
assert out.contains('special{kind="negative"} -Inf')
assert out.contains('special{kind="nan"} NaN')
}
fn test_scrape_while_registering_and_updating_instruments() {
mut r := new_registry()
mut c := r.counter('events')
mut g := r.gauge('level')
worker := spawn fn [mut r, mut c, mut g] () {
for i in 0 .. 500 {
c.add(1, {
'worker': 'one'
})
g.set(f64(i), {
'worker': 'one'
})
mut extra := r.gauge('extra_${i % 4}')
extra.set(f64(i), {})
}
}()
for _ in 0 .. 100 {
out := r.to_prometheus()
assert out.contains('# TYPE events counter')
}
worker.wait()
assert c.get({
'worker': 'one'
}) == 500
assert g.get({
'worker': 'one'
}) == 499
assert r.to_prometheus().contains('# TYPE extra_3 gauge')
}
fn test_invalid_instruments_fail_explicitly() {
dir := os.join_path(os.vtmp_dir(), 'metrics_invalid_${os.getpid()}')
os.mkdir_all(dir)!
defer { os.rmdir_all(dir) or {} }
cases := [
"mut c := r.counter('total'); c.add(-1, {})",
"r.histogram('latency', [2.0, 1.0])",
"r.counter('bad\\nname')",
"r.counter('latency_count'); r.histogram('latency', [1.0])",
"r.histogram('latency', [1.0]); r.counter('latency_count')",
]
for i, body in cases {
path := os.join_path(dir, 'invalid_${i}.v')
os.write_file(path, 'module main\nimport metrics\nfn main() {\nmut r := metrics.new_registry()\n${body}\n}\n')!
result := os.exec([@VEXE, 'run', path])
assert result.exit_code != 0, body
assert result.output.contains('metrics:'), result.output
}
}