///|
fn is_ascii_letter(char : Char) -> Bool {
match char {
'a'..='z' | 'A'..='Z' => true
_ => false
}
}
///|
fn is_ascii_digit(char : Char) -> Bool {
match char {
'0'..='9' => true
_ => false
}
}
///|
fn is_valid_identifier(value : String, allow_colon : Bool) -> Bool {
if value.is_empty() {
return false
}
let mut index = 0
for char in value {
let valid = if index == 0 {
is_ascii_letter(char) || char == '_' || (allow_colon && char == ':')
} else {
is_ascii_letter(char) ||
is_ascii_digit(char) ||
char == '_' ||
(allow_colon && char == ':')
}
if !valid {
return false
}
index += 1
}
true
}
///|
fn validate_family_name(name : String) -> Unit raise EncodingError {
if !is_valid_identifier(name, true) {
raise InvalidSnapshot("invalid metric family name: \{name}")
}
}
///|
fn validate_label_name(name : String) -> Unit raise EncodingError {
if !is_valid_identifier(name, false) || name.has_prefix("__") {
raise InvalidSnapshot("invalid label name: \{name}")
}
}
///|
fn validate_unit(unit : String) -> Unit raise EncodingError {
if !is_valid_identifier(unit, true) {
raise InvalidSnapshot("invalid unit: \{unit}")
}
}
///|
/// Compare text by Unicode scalar order, not MoonBit shortlex string ordering.
/// Prometheus tooling conventionally presents label and family names
/// lexicographically, so a one-character-longer name must not jump ahead just
/// because it is shorter.
fn lexicographic_compare(left : String, right : String) -> Int {
let left_chars = [ for char in left => char ]
let right_chars = [ for char in right => char ]
let shared_length = if left_chars.length() < right_chars.length() {
left_chars.length()
} else {
right_chars.length()
}
for index in 0.. right_chars.length() {
1
} else {
0
}
}
///|
fn sorted_labels(
labels : Array[@metric.Label],
) -> Array[@metric.Label] raise EncodingError {
let sorted = labels.copy()
for index in 0.. 0 &&
lexicographic_compare(sorted[position - 1].name, current.name) > 0 {
sorted[position] = sorted[position - 1]
position -= 1
}
sorted[position] = current
}
for index in 1.. String raise EncodingError {
let output = StringBuilder()
for label in sorted_labels(labels) {
output.write_string("\{label.name.length()}:\{label.name}")
output.write_string("\{label.value.length()}:\{label.value}")
}
output.to_string()
}
///|
fn write_labels(
output : StringBuilder,
labels : Array[@metric.Label],
extra? : @metric.Label,
) -> Unit raise EncodingError {
let sorted = sorted_labels(labels)
match extra {
Some(label) => {
validate_label_name(label.name)
for existing in sorted {
if existing.name == label.name {
raise DuplicateLabelName(label.name)
}
}
}
None => ()
}
if sorted.is_empty() && extra is None {
return
}
output.write_char('{')
for index, label in sorted {
if index > 0 {
output.write_char(',')
}
output.write_string(label.name)
output.write_string("=\"")
output.write_string(escape_label_value(label.value))
output.write_char('"')
}
match extra {
Some(label) => {
if !sorted.is_empty() {
output.write_char(',')
}
output.write_string(label.name)
output.write_string("=\"")
output.write_string(escape_label_value(label.value))
output.write_char('"')
}
None => ()
}
output.write_char('}')
}
///|
fn write_metadata(
output : StringBuilder,
name : String,
help : String,
metric_type : String,
unit : String?,
format : ExpositionFormat,
) -> Unit raise EncodingError {
validate_family_name(name)
if !help.is_empty() {
output.write_string("# HELP \{name} \{escape_help(help)}\n")
}
output.write_string("# TYPE \{name} \{metric_type}\n")
if is_openmetrics(format) {
match unit {
Some(value) => {
validate_unit(value)
if !name.has_suffix("_" + value) {
raise InvalidSnapshot(
"OpenMetrics unit '\{value}' must be an underscore suffix of family '\{name}'",
)
}
output.write_string("# UNIT \{name} \{value}\n")
}
None => ()
}
}
}
///|
/// Return the metadata name for one metric family in the selected wire format.
///
/// OpenMetrics 1.0 models a Counter family independently from its Total sample:
/// `# TYPE http_requests counter` is paired with `http_requests_total 1`.
/// Prometheus text retains the traditional single `http_requests_total` name
/// for both metadata and sample. Rejecting a missing suffix is preferable to
/// emitting a syntactically plausible but invalid OpenMetrics Counter.
fn metadata_name(
name : String,
family_type : @registry.FamilyType,
format : ExpositionFormat,
) -> String raise EncodingError {
if format != OpenMetricsText || family_type != CounterFamily {
return name
}
match name.strip_suffix("_total") {
Some(base) => {
let result = base.to_owned()
if result.is_empty() {
raise InvalidSnapshot(
"OpenMetrics counter name must have a base before _total: \{name}",
)
}
result
}
None =>
raise InvalidSnapshot(
"OpenMetrics counter sample name must end in _total: \{name}",
)
}
}
///|
fn write_scalar_sample(
output : StringBuilder,
name : String,
labels : Array[@metric.Label],
value : Double,
format : ExpositionFormat,
) -> Unit raise EncodingError {
output.write_string(name)
write_labels(output, labels)
output.write_char(' ')
let rendered = if is_openmetrics(format) {
format_openmetrics_number(value)
} else {
format_number(value)
}
output.write_string(rendered)
output.write_char('\n')
}
///|
fn write_histogram_sample(
output : StringBuilder,
name : String,
labels : Array[@metric.Label],
value : @metric.HistogramSnapshot,
format : ExpositionFormat,
) -> Unit raise EncodingError {
let boundaries = value.boundaries
let counts = value.cumulative_counts
if boundaries.length() != counts.length() {
raise InvalidHistogram("boundary/count length mismatch for \{name}")
}
let mut previous_boundary : Double? = None
let mut previous_count = 0L
for index, boundary in boundaries {
if boundary.is_nan() || boundary.is_inf() {
raise InvalidHistogram("non-finite boundary for \{name}")
}
match previous_boundary {
Some(previous) =>
if boundary <= previous {
raise InvalidHistogram(
"boundaries are not strictly increasing for \{name}",
)
}
None => ()
}
if counts[index] < previous_count || counts[index] > value.count {
raise InvalidHistogram("invalid cumulative bucket count for \{name}")
}
output.write_string("\{name}_bucket")
write_labels(output, labels, extra={
name: "le",
value: if is_openmetrics(format) {
format_openmetrics_number(boundary)
} else {
format_number(boundary)
},
})
output.write_char(' ')
output.write_string(format_count(counts[index]))
output.write_char('\n')
previous_boundary = Some(boundary)
previous_count = counts[index]
}
output.write_string("\{name}_bucket")
write_labels(output, labels, extra={ name: "le", value: "+Inf" })
output.write_char(' ')
output.write_string(format_count(value.count))
output.write_char('\n')
output.write_string("\{name}_sum")
write_labels(output, labels)
output.write_char(' ')
if is_openmetrics(format) {
output.write_string(format_openmetrics_number(value.sum))
} else {
output.write_string(format_number(value.sum))
}
output.write_char('\n')
output.write_string("\{name}_count")
write_labels(output, labels)
output.write_char(' ')
output.write_string(format_count(value.count))
output.write_char('\n')
}
///|
fn write_family(
output : StringBuilder,
family : @registry.FamilySnapshot,
format : ExpositionFormat,
) -> Unit raise EncodingError {
match family.family_type {
CounterFamily => {
if !family.histogram_samples.is_empty() {
raise InvalidSnapshot(
"counter family contains histogram samples: \{family.name}",
)
}
let metadata_name = metadata_name(family.name, CounterFamily, format)
write_metadata(
output,
metadata_name,
family.help,
"counter",
family.unit,
format,
)
let samples = family.scalar_samples.copy()
for index in 0.. 0 &&
lexicographic_compare(
label_sort_key(samples[position - 1].labels),
current_key,
) >
0 {
samples[position] = samples[position - 1]
position -= 1
}
samples[position] = current
}
for sample in samples {
write_scalar_sample(
output,
family.name,
sample.labels,
sample.value,
format,
)
}
}
GaugeFamily => {
if !family.histogram_samples.is_empty() {
raise InvalidSnapshot(
"gauge family contains histogram samples: \{family.name}",
)
}
let metadata_name = metadata_name(family.name, GaugeFamily, format)
write_metadata(
output,
metadata_name,
family.help,
"gauge",
family.unit,
format,
)
let samples = family.scalar_samples.copy()
for index in 0.. 0 &&
lexicographic_compare(
label_sort_key(samples[position - 1].labels),
current_key,
) >
0 {
samples[position] = samples[position - 1]
position -= 1
}
samples[position] = current
}
for sample in samples {
write_scalar_sample(
output,
family.name,
sample.labels,
sample.value,
format,
)
}
}
HistogramFamily => {
if !family.scalar_samples.is_empty() {
raise InvalidSnapshot(
"histogram family contains scalar samples: \{family.name}",
)
}
let metadata_name = metadata_name(family.name, HistogramFamily, format)
write_metadata(
output,
metadata_name,
family.help,
"histogram",
family.unit,
format,
)
let samples = family.histogram_samples.copy()
for index in 0.. 0 &&
lexicographic_compare(
label_sort_key(samples[position - 1].labels),
current_key,
) >
0 {
samples[position] = samples[position - 1]
position -= 1
}
samples[position] = current
}
for sample in samples {
write_histogram_sample(
output,
family.name,
sample.labels,
sample.value,
format,
)
}
}
}
}
///|
/// Encode an immutable gather snapshot in the requested text exposition format.
///
/// Family and sample order is canonicalized here, so equivalent snapshots
/// produce identical bytes regardless of collection order. Prometheus text
/// 0.0.4 omits `UNIT` and `# EOF`; OpenMetrics 1.0 includes both.
pub fn encode_snapshot(
snapshot : @registry.GatherSnapshot,
format : ExpositionFormat,
) -> EncodedExposition raise EncodingError {
let families = snapshot.families.copy()
for index in 0.. 0 &&
lexicographic_compare(families[position - 1].name, current.name) > 0 {
families[position] = families[position - 1]
position -= 1
}
families[position] = current
}
for index in 1.. EncodedExposition raise EncodingError {
let output = StringBuilder()
for counter in registry.counters() {
let metadata_name = metadata_name(counter.name(), CounterFamily, format)
write_metadata(
output,
metadata_name,
counter.help(),
"counter",
counter.unit(),
format,
)
write_scalar_sample(
output,
counter.name(),
counter.labels(),
counter.get(),
format,
)
}
for gauge in registry.gauges() {
let metadata_name = metadata_name(gauge.name(), GaugeFamily, format)
write_metadata(
output,
metadata_name,
gauge.help(),
"gauge",
gauge.unit(),
format,
)
write_scalar_sample(
output,
gauge.name(),
gauge.labels(),
gauge.get(),
format,
)
}
for histogram in registry.histograms() {
let metadata_name = metadata_name(histogram.name(), HistogramFamily, format)
write_metadata(
output,
metadata_name,
histogram.help(),
"histogram",
histogram.unit(),
format,
)
write_histogram_sample(
output,
histogram.name(),
histogram.labels(),
histogram.snapshot(),
format,
)
}
if is_openmetrics(format) {
output.write_string("# EOF\n")
}
{ format, content_type: content_type(format), body: output.to_string() }
}
///|
/// Convenience OpenMetrics entry point for a complete registry snapshot.
///
/// Unlike the scalar-only M0 compatibility function `encode_registry`, this
/// gathers every registered collector, including vectors and custom adapters.
/// New HTTP/exporter code should select an explicit format and use
/// `encode_snapshot(registry.gather(), format)` so gather and encoding errors
/// can be handled separately.
pub fn encode(registry : @registry.Registry) -> String {
try! encode_snapshot(registry.gather(), OpenMetricsText).body
}