///|
/// A deterministic in-memory metric registry.
///
/// The registry deliberately has no clock, threads, network or process-global
/// state. Callers supply labels and values, then snapshot or encode the result.
pub struct Registry {
priv families : Array[MetricFamily]
}
///|
/// Create an empty registry.
pub fn Registry::new() -> Registry {
{ families: [] }
}
///|
fn Registry::family_index(self : Registry, name : StringView) -> Int {
find_family_index(self.families, name)
}
///|
fn Registry::require_type(
self : Registry,
name : StringView,
expected : MetricType,
) -> Result[Int, String] {
let index = self.family_index(name)
if index < 0 {
return Err("metric family '\{name}' is not registered")
}
let actual = self.families[index].metric_type
if actual != expected {
return Err(
"metric family '\{name}' has type \{actual.to_keyword()}, expected \{expected.to_keyword()}",
)
}
Ok(index)
}
///|
/// Register metadata for a new metric family.
pub fn Registry::register(
self : Registry,
name : String,
metric_type : MetricType,
help? : String,
unit? : String,
) -> Result[Unit, String] {
if !is_valid_metric_name(name) {
return Err("invalid metric family name '\{name}'")
}
if metric_type == Unknown {
return Err("registry families require a concrete metric type")
}
if self.family_index(name) >= 0 {
return Err("metric family '\{name}' is already registered")
}
self.families.push(MetricFamily::new(name, metric_type~, help?, unit?))
Ok(())
}
///|
fn find_series_index(
samples : Array[Sample],
name : String,
labels : Array[Label],
) -> Int {
for index in 0.. MetricFamily {
let samples = family.samples.copy()
let index = find_series_index(samples, name, labels)
if index < 0 {
samples.push(Sample::new(name, value, labels~))
} else {
let current = samples[index]
let next_value = if add { current.value + value } else { value }
samples[index] = { ..current, value: next_value }
}
{ ..family, samples, }
}
///|
/// Set or replace an arbitrary sample in a registered family.
///
/// This lower-level operation is useful for summary and info collectors.
pub fn Registry::set_sample(
self : Registry,
family_name : StringView,
sample : Sample,
) -> Result[Unit, String] {
let index = self.family_index(family_name)
if index < 0 {
return Err("metric family '\{family_name}' is not registered")
}
let family = self.families[index]
if !sample_matches_family(family, sample.name) {
return Err("sample '\{sample.name}' does not match family '\{family.name}'")
}
let samples = family.samples.copy()
let sample_index = find_series_index(samples, sample.name, sample.labels)
if sample_index < 0 {
samples.push(sample)
} else {
samples[sample_index] = sample
}
self.families[index] = { ..family, samples, }
Ok(())
}
///|
/// Increment a counter series. Negative deltas are rejected.
pub fn Registry::increment_counter(
self : Registry,
name : StringView,
delta? : Double = 1.0,
labels? : Array[Label] = [],
) -> Result[Unit, String] {
if delta.is_nan() || delta < 0.0 {
return Err("counter delta must be a non-negative number")
}
let index = match self.require_type(name, Counter) {
Ok(index) => index
Err(error) => return Err(error)
}
let family = self.families[index]
self.families[index] = update_or_insert(
family,
"\{family.name}_total",
labels,
delta,
true,
)
Ok(())
}
///|
/// Set a gauge series to an absolute value.
pub fn Registry::set_gauge(
self : Registry,
name : StringView,
value : Double,
labels? : Array[Label] = [],
) -> Result[Unit, String] {
let index = match self.require_type(name, Gauge) {
Ok(index) => index
Err(error) => return Err(error)
}
let family = self.families[index]
self.families[index] = update_or_insert(
family,
family.name,
labels,
value,
false,
)
Ok(())
}
///|
/// Set an info series. Info samples always carry the value `1`.
pub fn Registry::set_info(
self : Registry,
name : StringView,
labels? : Array[Label] = [],
) -> Result[Unit, String] {
let index = match self.require_type(name, Info) {
Ok(index) => index
Err(error) => return Err(error)
}
let family = self.families[index]
self.families[index] = update_or_insert(
family,
"\{family.name}_info",
labels,
1.0,
false,
)
Ok(())
}
///|
/// Set one state in a state-set family.
pub fn Registry::set_state(
self : Registry,
name : StringView,
state : String,
enabled : Bool,
labels? : Array[Label] = [],
) -> Result[Unit, String] {
if state == "" {
return Err("state name must not be empty")
}
let index = match self.require_type(name, StateSet) {
Ok(index) => index
Err(error) => return Err(error)
}
let family = self.families[index]
if labels_have_name(labels, family.name) {
return Err(
"caller labels must not contain the reserved state label '\{family.name}'",
)
}
let state_labels = labels.copy()
state_labels.push(Label::new(family.name, state))
self.families[index] = update_or_insert(
family,
family.name,
state_labels,
if enabled {
1.0
} else {
0.0
},
false,
)
Ok(())
}
///|
fn clear_summary_group(
family : MetricFamily,
labels : Array[Label],
) -> MetricFamily {
let samples : Array[Sample] = []
for sample in family.samples {
let belongs = if sample.name == family.name {
same_label_group_ignoring(sample.labels, labels, "quantile")
} else if sample.name == "\{family.name}_sum" ||
sample.name == "\{family.name}_count" {
same_label_set(sample.labels, labels)
} else {
false
}
if !belongs {
samples.push(sample)
}
}
{ ..family, samples, }
}
///|
fn labels_with_quantile(
labels : Array[Label],
quantile : Double,
) -> Array[Label] {
let result = labels.copy()
result.push(Label::new("quantile", format_number(quantile)))
result
}
///|
/// Replace one complete summary label group.
///
/// Quantile keys must be strictly increasing from zero to one, and observed
/// quantile values must be non-decreasing.
pub fn Registry::set_summary(
self : Registry,
name : StringView,
quantiles : Array[(Double, Double)],
sum : Double,
count : Double,
labels? : Array[Label] = [],
) -> Result[Unit, String] {
if labels_have_name(labels, "quantile") {
return Err("summary labels must not contain the reserved 'quantile' label")
}
if !is_nonnegative_integer(count) {
return Err("summary count must be a non-negative integer")
}
if sum.is_nan() || sum < 0.0 {
return Err("summary sum must be non-NaN and non-negative")
}
let mut previous_quantile = -1.0
let mut previous_value = -@double.infinity
for pair in quantiles {
if pair.0.is_nan() || pair.0 < 0.0 || pair.0 > 1.0 {
return Err("summary quantiles must be between 0 and 1")
}
if pair.0 <= previous_quantile {
return Err("summary quantiles must be strictly increasing")
}
if pair.1 < 0.0 {
return Err("summary values must not be negative")
}
if count == 0.0 && !pair.1.is_nan() {
return Err("summary values must be NaN when count is zero")
}
if pair.1 < previous_value {
return Err("summary values must not decrease across quantiles")
}
previous_quantile = pair.0
previous_value = pair.1
}
let index = match self.require_type(name, Summary) {
Ok(index) => index
Err(error) => return Err(error)
}
let original = self.families[index]
let mut family = clear_summary_group(original, labels)
for pair in quantiles {
family = update_or_insert(
family,
family.name,
labels_with_quantile(labels, pair.0),
pair.1,
false,
)
}
family = update_or_insert(family, "\{family.name}_sum", labels, sum, false)
family = update_or_insert(
family,
"\{family.name}_count",
labels,
count,
false,
)
self.families[index] = family
Ok(())
}
///|
fn labels_with_bound(labels : Array[Label], bound : String) -> Array[Label] {
let result = labels.copy()
result.push(Label::new("le", bound))
result
}
///|
fn clear_gaugehistogram_group(
family : MetricFamily,
labels : Array[Label],
) -> MetricFamily {
let samples : Array[Sample] = []
for sample in family.samples {
let belongs = if sample.name == "\{family.name}_bucket" {
same_label_group_ignoring(sample.labels, labels, "le")
} else if sample.name == "\{family.name}_gsum" ||
sample.name == "\{family.name}_gcount" {
same_label_set(sample.labels, labels)
} else {
false
}
if !belongs {
samples.push(sample)
}
}
{ ..family, samples, }
}
///|
/// Replace one complete gauge-histogram label group.
///
/// Each tuple contains a finite upper bound and its cumulative bucket count.
pub fn Registry::set_gauge_histogram(
self : Registry,
name : StringView,
buckets : Array[(Double, Double)],
sum : Double,
count : Double,
labels? : Array[Label] = [],
) -> Result[Unit, String] {
if labels_have_name(labels, "le") {
return Err(
"gauge-histogram labels must not contain the reserved 'le' label",
)
}
if !is_nonnegative_integer(count) {
return Err("gauge-histogram count must be a non-negative integer")
}
if sum.is_nan() {
return Err("gauge-histogram sum must not be NaN")
}
let mut previous_bound = -@double.infinity
let mut previous_count = 0.0
for bucket in buckets {
if bucket.0.is_nan() || bucket.0 == @double.infinity {
return Err("gauge-histogram finite bucket boundaries are required")
}
if bucket.0 <= previous_bound {
return Err("gauge-histogram bounds must be strictly increasing")
}
if !is_nonnegative_integer(bucket.1) || bucket.1 < previous_count {
return Err(
"gauge-histogram cumulative counts must be non-negative integers and must not decrease",
)
}
previous_bound = bucket.0
previous_count = bucket.1
}
if previous_count > count {
return Err("final finite bucket count must not exceed total count")
}
let index = match self.require_type(name, GaugeHistogram) {
Ok(index) => index
Err(error) => return Err(error)
}
let original = self.families[index]
let mut family = clear_gaugehistogram_group(original, labels)
for bucket in buckets {
family = update_or_insert(
family,
"\{family.name}_bucket",
labels_with_bound(labels, format_number(bucket.0)),
bucket.1,
false,
)
}
family = update_or_insert(
family,
"\{family.name}_bucket",
labels_with_bound(labels, "+Inf"),
count,
false,
)
family = update_or_insert(family, "\{family.name}_gsum", labels, sum, false)
family = update_or_insert(
family,
"\{family.name}_gcount",
labels,
count,
false,
)
self.families[index] = family
Ok(())
}
///|
fn validate_buckets(buckets : Array[Double]) -> Result[Unit, String] {
let mut previous = -@double.infinity
for bound in buckets {
if bound.is_nan() || bound == @double.infinity || bound < 0.0 {
return Err(
"non-negative finite histogram bucket boundaries are required because the registry exposes a sum",
)
}
if bound <= previous {
return Err("histogram bucket boundaries must be strictly increasing")
}
previous = bound
}
Ok(())
}
///|
fn validate_existing_histogram_schema(
family : MetricFamily,
labels : Array[Label],
buckets : Array[Double],
) -> Result[Unit, String] {
let bucket_name = "\{family.name}_bucket"
let existing : Array[Double] = []
let mut saw_bucket = false
for sample in family.samples {
if sample.name != bucket_name ||
!same_label_group_ignoring(sample.labels, labels, "le") {
continue
}
saw_bucket = true
match label_number(sample, "le") {
Some(bound) => if bound != @double.infinity { existing.push(bound) }
None =>
return Err(
"existing histogram bucket schema contains an invalid boundary",
)
}
}
if !saw_bucket {
return Ok(())
}
if existing.length() != buckets.length() {
return Err(
"histogram bucket boundaries must match the existing schema for this label set",
)
}
for index in 0.. Result[Unit, String] {
if labels_have_name(labels, "le") {
return Err("histogram labels must not contain the reserved 'le' label")
}
if value.is_nan() || value < 0.0 {
return Err(
"histogram observations must be non-NaN and non-negative because the registry exposes a sum",
)
}
match validate_buckets(buckets) {
Err(error) => return Err(error)
Ok(_) => ()
}
let index = match self.require_type(name, Histogram) {
Ok(index) => index
Err(error) => return Err(error)
}
let mut family = self.families[index]
match validate_existing_histogram_schema(family, labels, buckets) {
Err(error) => return Err(error)
Ok(_) => ()
}
for bound in buckets {
if value <= bound {
family = update_or_insert(
family,
"\{family.name}_bucket",
labels_with_bound(labels, format_number(bound)),
1.0,
true,
)
} else {
let bound_labels = labels_with_bound(labels, format_number(bound))
if find_series_index(
family.samples,
"\{family.name}_bucket",
bound_labels,
) <
0 {
family = update_or_insert(
family,
"\{family.name}_bucket",
bound_labels,
0.0,
false,
)
}
}
}
family = update_or_insert(
family,
"\{family.name}_bucket",
labels_with_bound(labels, "+Inf"),
1.0,
true,
)
family = update_or_insert(family, "\{family.name}_sum", labels, value, true)
family = update_or_insert(family, "\{family.name}_count", labels, 1.0, true)
self.families[index] = family
Ok(())
}
///|
/// Copy the current registry into an OpenMetrics document.
pub fn Registry::snapshot(self : Registry) -> Document {
let families : Array[MetricFamily] = []
for family in self.families {
families.push({ ..family, samples: family.samples.copy() })
}
{ families, has_eof: true }
}
///|
/// Remove all samples from a registered family while retaining its metadata.
pub fn Registry::reset_family(
self : Registry,
name : StringView,
) -> Result[Unit, String] {
let index = self.family_index(name)
if index < 0 {
return Err("metric family '\{name}' is not registered")
}
let family = self.families[index]
self.families[index] = { ..family, samples: [] }
Ok(())
}
///|
/// Remove an entire family and return whether it existed.
pub fn Registry::remove_family(self : Registry, name : StringView) -> Bool {
let index = self.family_index(name)
if index < 0 {
false
} else {
self.families.remove(index) |> ignore
true
}
}
///|
/// Encode a snapshot of the registry.
pub fn Registry::to_text(self : Registry) -> String {
encode(self.snapshot())
}
///|
/// Return the number of registered families.
pub fn Registry::length(self : Registry) -> Int {
self.families.length()
}