use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
pub const MAX_SAMPLES: usize = 50_000;
pub const MAX_NAME_LEN: usize = 256;
pub const MAX_TEXT_LEN: usize = 16 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Text,
OpenMetrics,
}
impl Format {
pub fn content_type(self) -> &'static str {
match self {
Format::Text => "text/plain; version=0.0.4; charset=utf-8",
Format::OpenMetrics => "application/openmetrics-text; version=1.0.0; charset=utf-8",
}
}
pub fn as_str(self) -> &'static str {
match self {
Format::Text => "text",
Format::OpenMetrics => "openmetrics",
}
}
}
pub fn negotiate(accept: Option<&str>) -> Format {
let Some(accept) = accept else {
return Format::Text;
};
let mut best_openmetrics: f32 = -1.0;
let mut best_other: f32 = -1.0;
for entry in accept.split(',') {
let mut parts = entry.split(';');
let media = parts.next().unwrap_or("").trim().to_ascii_lowercase();
let mut q: f32 = 1.0;
for param in parts {
if let Some((k, v)) = param.split_once('=') {
if k.trim().eq_ignore_ascii_case("q") {
q = v.trim().parse().unwrap_or(0.0);
}
}
}
if !q.is_finite() || q <= 0.0 {
continue;
}
if media == "application/openmetrics-text" {
best_openmetrics = best_openmetrics.max(q);
} else if media == "text/plain" || media == "*/*" || media == "text/*" {
best_other = best_other.max(q);
}
}
if best_openmetrics > best_other {
Format::OpenMetrics
} else {
Format::Text
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MetricType {
Counter,
Gauge,
Histogram,
Summary,
Untyped,
}
impl MetricType {
fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"counter" => Some(Self::Counter),
"gauge" => Some(Self::Gauge),
"histogram" => Some(Self::Histogram),
"summary" => Some(Self::Summary),
"untyped" | "unknown" => Some(Self::Untyped),
_ => None,
}
}
fn type_word(self, format: Format) -> &'static str {
match (self, format) {
(Self::Counter, _) => "counter",
(Self::Gauge, _) => "gauge",
(Self::Histogram, _) => "histogram",
(Self::Summary, _) => "summary",
(Self::Untyped, Format::Text) => "untyped",
(Self::Untyped, Format::OpenMetrics) => "unknown",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Suffix {
None,
Bucket,
Sum,
Count,
}
impl Suffix {
fn text(self, mtype: MetricType) -> &'static str {
match (self, mtype) {
(Suffix::None, MetricType::Counter) => "_total",
(Suffix::None, _) => "",
(Suffix::Bucket, _) => "_bucket",
(Suffix::Sum, _) => "_sum",
(Suffix::Count, _) => "_count",
}
}
}
#[derive(Clone, Debug)]
struct Sample {
suffix: Suffix,
labels: BTreeMap<String, String>,
bound: Option<f64>,
value: f64,
timestamp_ms: Option<i64>,
}
#[derive(Clone, Debug)]
struct Family {
base: String,
mtype: MetricType,
help: Option<String>,
samples: Vec<Sample>,
}
#[derive(Clone, Debug)]
pub struct MetricFamilies {
families: Vec<Family>,
}
fn valid_metric_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
}
fn valid_label_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn parse_value(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => match s.trim() {
"NaN" | "nan" => Some(f64::NAN),
"+Inf" | "Inf" | "inf" | "+inf" => Some(f64::INFINITY),
"-Inf" | "-inf" => Some(f64::NEG_INFINITY),
other => other.parse::<f64>().ok(),
},
_ => None,
}
}
fn format_value(v: f64) -> String {
if v.is_nan() {
"NaN".to_string()
} else if v == f64::INFINITY {
"+Inf".to_string()
} else if v == f64::NEG_INFINITY {
"-Inf".to_string()
} else {
format!("{v}")
}
}
fn format_bound(v: f64) -> String {
if v == f64::INFINITY {
"+Inf".to_string()
} else if v == f64::NEG_INFINITY {
"-Inf".to_string()
} else if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{v:.1}")
} else {
format!("{v}")
}
}
fn escape_label_value(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
other => out.push(other),
}
}
out
}
fn escape_help(s: &str, format: Format) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'"' if format == Format::OpenMetrics => out.push_str("\\\""),
other => out.push(other),
}
}
out
}
impl MetricFamilies {
pub fn parse(metrics: &Value) -> Result<Self, String> {
let list = metrics
.as_array()
.ok_or("'metrics' must be an array of metric families")?;
let mut families = Vec::with_capacity(list.len());
let mut seen_bases: BTreeSet<String> = BTreeSet::new();
let mut total_samples = 0usize;
for (i, raw) in list.iter().enumerate() {
let name = raw
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| format!("metrics[{i}] is missing a string 'name'"))?;
if name.len() > MAX_NAME_LEN {
return Err(format!(
"metric name '{}…' is longer than {MAX_NAME_LEN} characters",
&name[..name
.char_indices()
.nth(32)
.map(|(b, _)| b)
.unwrap_or(name.len())]
));
}
if !valid_metric_name(name) {
return Err(format!(
"metric name '{name}' is invalid: it must match [a-zA-Z_:][a-zA-Z0-9_:]*"
));
}
let type_str = raw.get("type").and_then(Value::as_str).unwrap_or("untyped");
let mtype = MetricType::parse(type_str).ok_or_else(|| {
format!(
"metric '{name}' has type '{type_str}'; it must be one of counter, gauge, \
histogram, summary, untyped"
)
})?;
let help = match raw.get("help") {
None | Some(Value::Null) => None,
Some(Value::String(s)) if s.len() > MAX_TEXT_LEN => {
return Err(format!(
"metric '{name}' help text is longer than {MAX_TEXT_LEN} bytes"
))
}
Some(Value::String(s)) => Some(s.clone()),
Some(_) => return Err(format!("metric '{name}' help must be a string")),
};
let base = match mtype {
MetricType::Counter => name.strip_suffix("_total").unwrap_or(name).to_string(),
_ => name.to_string(),
};
if base.is_empty() {
return Err(format!("metric name '{name}' is only a suffix"));
}
if !seen_bases.insert(base.clone()) {
return Err(format!(
"metric family '{name}' appears twice; put all of its samples in one entry"
));
}
let raw_samples = match raw.get("samples") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(a)) => a.clone(),
Some(_) => return Err(format!("metric '{name}' samples must be an array")),
};
total_samples = total_samples.saturating_add(raw_samples.len());
if total_samples > MAX_SAMPLES {
return Err(format!(
"more than {MAX_SAMPLES} samples in one answer; send fewer series"
));
}
let mut samples = Vec::with_capacity(raw_samples.len());
for (j, s) in raw_samples.iter().enumerate() {
samples.push(parse_sample(name, mtype, j, s)?);
}
let mut family = Family {
base,
mtype,
help,
samples,
};
normalise(&mut family, name)?;
families.push(family);
}
let mut owner: BTreeMap<String, String> = BTreeMap::new();
for f in &families {
for series in series_names(f) {
if let Some(other) = owner.insert(series.clone(), f.base.clone()) {
if other != f.base {
return Err(format!(
"sample name '{series}' is produced by both '{other}' and '{}'; \
rename one of them",
f.base
));
}
}
}
}
Ok(Self { families })
}
pub fn family_count(&self) -> usize {
self.families.len()
}
pub fn sample_count(&self) -> usize {
self.families.iter().map(|f| f.samples.len()).sum()
}
pub fn render(&self, format: Format) -> String {
let mut out = String::new();
for f in &self.families {
let family_name = match (f.mtype, format) {
(MetricType::Counter, Format::Text) => format!("{}_total", f.base),
_ => f.base.clone(),
};
if let Some(help) = &f.help {
out.push_str("# HELP ");
out.push_str(&family_name);
out.push(' ');
out.push_str(&escape_help(help, format));
out.push('\n');
}
out.push_str("# TYPE ");
out.push_str(&family_name);
out.push(' ');
out.push_str(f.mtype.type_word(format));
out.push('\n');
for s in &f.samples {
out.push_str(&f.base);
out.push_str(s.suffix.text(f.mtype));
let mut labels: Vec<(String, String)> = s
.labels
.iter()
.map(|(k, v)| (k.clone(), escape_label_value(v)))
.collect();
if let Some(bound) = s.bound {
let key = if f.mtype == MetricType::Histogram {
"le"
} else {
"quantile"
};
labels.push((key.to_string(), format_bound(bound)));
}
if !labels.is_empty() {
out.push('{');
for (idx, (k, v)) in labels.iter().enumerate() {
if idx > 0 {
out.push(',');
}
out.push_str(k);
out.push_str("=\"");
out.push_str(v);
out.push('"');
}
out.push('}');
}
out.push(' ');
out.push_str(&format_value(s.value));
if let Some(ts) = s.timestamp_ms {
out.push(' ');
match format {
Format::Text => out.push_str(&ts.to_string()),
Format::OpenMetrics => {
let secs = ts.div_euclid(1000);
let millis = ts.rem_euclid(1000);
out.push_str(&format!("{secs}.{millis:03}"));
}
}
}
out.push('\n');
}
}
if format == Format::OpenMetrics {
out.push_str("# EOF\n");
}
out
}
}
fn series_names(f: &Family) -> Vec<String> {
let suffixes: &[&str] = match f.mtype {
MetricType::Counter => &["_total"],
MetricType::Gauge | MetricType::Untyped => &[""],
MetricType::Histogram => &["_bucket", "_sum", "_count"],
MetricType::Summary => &["", "_sum", "_count"],
};
suffixes
.iter()
.map(|s| format!("{}{}", f.base, s))
.collect()
}
fn parse_sample(name: &str, mtype: MetricType, j: usize, s: &Value) -> Result<Sample, String> {
let where_ = || format!("metric '{name}' sample {j}");
let value = s
.get("value")
.and_then(parse_value)
.ok_or_else(|| format!("{} has no numeric 'value'", where_()))?;
let suffix_str = s.get("suffix").and_then(Value::as_str).unwrap_or("");
let suffix = match (mtype, suffix_str) {
(MetricType::Counter, "" | "_total") => Suffix::None,
(MetricType::Gauge | MetricType::Untyped, "") => Suffix::None,
(MetricType::Histogram, "_bucket") => Suffix::Bucket,
(MetricType::Histogram | MetricType::Summary, "_sum") => Suffix::Sum,
(MetricType::Histogram | MetricType::Summary, "_count") => Suffix::Count,
(MetricType::Summary, "") => Suffix::None,
(MetricType::Histogram, "") => {
return Err(format!(
"{} needs a suffix: a histogram sample is _bucket (with an 'le' label), _sum or \
_count",
where_()
))
}
(_, other) => {
return Err(format!(
"{} has suffix '{other}', which a {} does not have",
where_(),
mtype.type_word(Format::Text)
))
}
};
let mut labels = BTreeMap::new();
let mut bound: Option<f64> = None;
match s.get("labels") {
None | Some(Value::Null) => {}
Some(Value::Object(map)) => {
for (k, v) in map {
if k.len() > MAX_NAME_LEN || !valid_label_name(k) {
return Err(format!(
"{}: label name '{k}' is invalid: it must match [a-zA-Z_][a-zA-Z0-9_]*",
where_()
));
}
if k.starts_with("__") {
return Err(format!(
"{}: label name '{k}' begins with '__', which Prometheus reserves",
where_()
));
}
let text = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => {
return Err(format!(
"{}: label '{k}' must be a string, number or boolean",
where_()
))
}
};
if text.len() > MAX_TEXT_LEN {
return Err(format!(
"{}: label '{k}' is longer than {MAX_TEXT_LEN} bytes",
where_()
));
}
let is_le = k == "le" && mtype == MetricType::Histogram;
let is_quantile = k == "quantile" && mtype == MetricType::Summary;
if is_le || is_quantile {
let b = parse_value(v).filter(|b| !b.is_nan()).ok_or_else(|| {
format!("{}: '{k}' must be a number or \"+Inf\"", where_())
})?;
bound = Some(b);
} else if k == "le" || k == "quantile" {
return Err(format!(
"{}: '{k}' is reserved for {} samples",
where_(),
if k == "le" {
"histogram _bucket"
} else {
"summary quantile"
}
));
} else {
labels.insert(k.clone(), text);
}
}
}
Some(_) => return Err(format!("{}: 'labels' must be an object", where_())),
}
match (mtype, suffix) {
(MetricType::Histogram, Suffix::Bucket) if bound.is_none() => {
return Err(format!("{} is a _bucket without an 'le' label", where_()))
}
(MetricType::Histogram, Suffix::Sum | Suffix::Count) if bound.is_some() => {
return Err(format!(
"{}: 'le' belongs on _bucket samples only",
where_()
))
}
(MetricType::Summary, Suffix::None) => match bound {
None => {
return Err(format!(
"{} is a summary quantile without a 'quantile' label",
where_()
))
}
Some(q) if !(0.0..=1.0).contains(&q) => {
return Err(format!("{}: quantile {q} is outside 0..1", where_()))
}
_ => {}
},
(MetricType::Summary, Suffix::Sum | Suffix::Count) if bound.is_some() => {
return Err(format!(
"{}: 'quantile' belongs on quantile samples only",
where_()
))
}
_ => {}
}
let counts_things = matches!(
(mtype, suffix),
(MetricType::Counter, _) | (_, Suffix::Bucket) | (_, Suffix::Count)
);
if counts_things && (value.is_nan() || value < 0.0) {
return Err(format!(
"{}: value {} is not a valid count; counters, buckets and _count are non-negative",
where_(),
format_value(value)
));
}
let timestamp_ms = match s.get("timestamp_ms") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_i64().ok_or_else(|| {
format!(
"{}: timestamp_ms must be an integer (milliseconds since the epoch)",
where_()
)
})?),
};
Ok(Sample {
suffix,
labels,
bound,
value,
timestamp_ms,
})
}
fn normalise(f: &mut Family, name: &str) -> Result<(), String> {
let mut seen = BTreeSet::new();
for s in &f.samples {
let key = (s.suffix, s.labels.clone(), s.bound.map(|b| b.to_bits()));
if !seen.insert(key) {
return Err(format!(
"metric '{name}' has two samples with the same labels{}; each series may \
appear once",
match s.bound {
Some(b) => format!(" and bound {}", format_bound(b)),
None => String::new(),
}
));
}
}
if f.mtype != MetricType::Histogram && f.mtype != MetricType::Summary {
return Ok(());
}
let mut groups: BTreeMap<BTreeMap<String, String>, Vec<Sample>> = BTreeMap::new();
let mut order: Vec<BTreeMap<String, String>> = Vec::new();
for s in f.samples.drain(..) {
if !groups.contains_key(&s.labels) {
order.push(s.labels.clone());
}
groups.entry(s.labels.clone()).or_default().push(s);
}
let mut out = Vec::new();
for labels in order {
let mut group = groups.remove(&labels).unwrap_or_default();
group.sort_by(|a, b| {
a.suffix.cmp(&b.suffix).then(
a.bound
.unwrap_or(0.0)
.partial_cmp(&b.bound.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal),
)
});
if f.mtype == MetricType::Histogram {
let buckets: Vec<&Sample> = group
.iter()
.filter(|s| s.suffix == Suffix::Bucket)
.collect();
let count = group.iter().find(|s| s.suffix == Suffix::Count).cloned();
let mut previous = 0.0f64;
for b in &buckets {
if b.value < previous {
return Err(format!(
"histogram '{name}' bucket le={} holds {} but a smaller bucket holds \
{}; buckets are cumulative, so each must be at least the one before",
format_bound(b.bound.unwrap_or(0.0)),
format_value(b.value),
format_value(previous)
));
}
previous = b.value;
}
let has_inf = buckets
.last()
.is_some_and(|b| b.bound == Some(f64::INFINITY));
let inf_value = if has_inf {
previous
} else {
let v = count.as_ref().map(|c| c.value).unwrap_or(previous);
if v < previous {
return Err(format!(
"histogram '{name}' _count {} is smaller than its largest bucket {}",
format_value(v),
format_value(previous)
));
}
let template = group.iter().find(|s| s.suffix == Suffix::Bucket).cloned();
if let Some(mut inf) = template.or_else(|| count.clone()) {
inf.suffix = Suffix::Bucket;
inf.bound = Some(f64::INFINITY);
inf.value = v;
let insert_at = group
.iter()
.position(|s| s.suffix != Suffix::Bucket)
.unwrap_or(group.len());
group.insert(insert_at, inf);
v
} else {
return Err(format!(
"histogram '{name}' has a _sum but no buckets and no _count"
));
}
};
match count {
Some(c) if c.value != inf_value => {
return Err(format!(
"histogram '{name}' _count is {} but its +Inf bucket is {}; they are \
the same number by definition",
format_value(c.value),
format_value(inf_value)
))
}
Some(_) => {}
None => {
let mut c = group
.iter()
.find(|s| s.suffix == Suffix::Bucket)
.cloned()
.expect("a +Inf bucket was just ensured");
c.suffix = Suffix::Count;
c.bound = None;
c.value = inf_value;
group.push(c);
}
}
}
out.extend(group);
}
f.samples = out;
Ok(())
}