use std::collections::{BTreeMap, HashMap, HashSet};
use std::future::Future;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, PoisonError, RwLock};
use std::time::{Duration, Instant};
use serde::Serialize;
pub const MAX_SERIES_PER_METRIC: usize = 100;
pub const MAX_INSTRUMENTS: usize = 256;
pub const MAX_LABELS_PER_SERIES: usize = 8;
pub const MAX_LABEL_VALUE_LEN: usize = 128;
pub const MAX_METRIC_NAME_LEN: usize = 128;
pub const MAX_LABEL_NAME_LEN: usize = 128;
pub const MAX_HELP_LEN: usize = 512;
pub const DEFAULT_BUCKETS: &[f64] = &[
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
pub const MAX_BUCKET_BOUNDS: usize = 20;
const LOG_NAME_PREVIEW_LEN: usize = 64;
const RESERVED_LABEL_NAMES: [&str; 2] = ["le", "quantile"];
pub(crate) const HISTOGRAM_SUFFIXES: [&str; 3] = ["_bucket", "_sum", "_count"];
static REGISTRY: LazyLock<Registry> = LazyLock::new(Registry::default);
type PendingHelp = HashMap<Box<str>, (InstrumentKind, Box<str>)>;
#[derive(Debug, Default)]
struct Registry {
instruments: RwLock<HashMap<Box<str>, Arc<Instrument>>>,
pending_buckets: RwLock<HashMap<Box<str>, Box<[f64]>>>,
pending_help: RwLock<PendingHelp>,
warned_names: RwLock<HashSet<Box<str>>>,
over_capacity_warned: AtomicBool,
pending_buckets_full_warned: AtomicBool,
pending_help_full_warned: AtomicBool,
}
impl Registry {
fn warn_once(&self, name: &str, reason: &'static str) {
{
let seen = self
.warned_names
.read()
.unwrap_or_else(PoisonError::into_inner);
if seen.contains(name) || seen.len() >= MAX_INSTRUMENTS {
return;
}
}
{
let mut seen = self
.warned_names
.write()
.unwrap_or_else(PoisonError::into_inner);
if seen.len() >= MAX_INSTRUMENTS || !seen.insert(name.into()) {
return;
}
}
tracing::warn!(
metric = %sanitize_for_log(name),
reason,
"app metric rejected; recording through an inert handle"
);
}
fn warn_over_capacity(&self, name: &str) {
if !self.over_capacity_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
metric = %sanitize_for_log(name),
cap = MAX_INSTRUMENTS,
"app metric registry is at capacity; further new metric names are ignored"
);
}
}
fn warn_pending_buckets_full(&self, name: &str) {
if !self
.pending_buckets_full_warned
.swap(true, Ordering::Relaxed)
{
tracing::warn!(
metric = %sanitize_for_log(name),
cap = MAX_INSTRUMENTS,
"too many histograms have bucket bounds configured but were never registered; \
ignoring further `set_histogram_buckets` calls for unregistered names"
);
}
}
fn warn_pending_help_full(&self, name: &str) {
if !self.pending_help_full_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
metric = %sanitize_for_log(name),
cap = MAX_INSTRUMENTS,
"too many app metrics have been described but were never registered; \
ignoring further `describe_*` calls for unregistered names"
);
}
}
}
fn sanitize_for_log(name: &str) -> String {
let preview: String = name.escape_debug().take(LOG_NAME_PREVIEW_LEN).collect();
if name.escape_debug().nth(LOG_NAME_PREVIEW_LEN).is_some() {
return format!("{preview}…");
}
preview
}
type SeriesKey = Box<[(Box<str>, Box<str>)]>;
#[derive(Debug)]
struct Instrument {
name: Box<str>,
kind: InstrumentKind,
help: RwLock<Box<str>>,
bounds: Box<[f64]>,
unlabeled: Series,
unlabeled_used: AtomicBool,
series: RwLock<HashMap<SeriesKey, Arc<Series>>>,
dropped: AtomicU64,
cap_warned: AtomicBool,
kind_warned: AtomicBool,
label_warned: AtomicBool,
value_warned: AtomicBool,
}
impl Instrument {
fn new(name: &str, kind: InstrumentKind, bounds: Box<[f64]>) -> Self {
Self {
name: name.into(),
kind,
help: RwLock::new(Box::default()),
unlabeled: Series::new(kind, bounds.len()),
bounds,
unlabeled_used: AtomicBool::new(false),
series: RwLock::new(HashMap::new()),
dropped: AtomicU64::new(0),
cap_warned: AtomicBool::new(false),
kind_warned: AtomicBool::new(false),
label_warned: AtomicBool::new(false),
value_warned: AtomicBool::new(false),
}
}
fn record(&self, labels: &[(String, String)], update: impl FnOnce(&Series)) {
if labels.is_empty() {
self.record_unlabeled(update);
return;
}
let key = self.canonical_key(labels);
if key.is_empty() {
self.record_unlabeled(update);
return;
}
if let Some(series) = self.series_for(&key) {
update(&series);
}
}
fn record_unlabeled(&self, update: impl FnOnce(&Series)) {
self.unlabeled_used.store(true, Ordering::Relaxed);
update(&self.unlabeled);
}
fn series_for(&self, key: &SeriesKey) -> Option<Arc<Series>> {
{
let series = self.series.read().unwrap_or_else(PoisonError::into_inner);
if let Some(existing) = series.get(key) {
return Some(Arc::clone(existing));
}
if series.len() >= MAX_SERIES_PER_METRIC {
drop(series);
self.note_dropped();
return None;
}
}
let fresh = Arc::new(Series::new(self.kind, self.bounds.len()));
{
let mut series = self.series.write().unwrap_or_else(PoisonError::into_inner);
if let Some(existing) = series.get(key) {
return Some(Arc::clone(existing));
}
if series.len() >= MAX_SERIES_PER_METRIC {
drop(series);
self.note_dropped();
return None;
}
series.insert(key.clone(), Arc::clone(&fresh));
}
Some(fresh)
}
fn note_dropped(&self) {
self.dropped.fetch_add(1, Ordering::Relaxed);
if !self.cap_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
metric = %self.name,
cap = MAX_SERIES_PER_METRIC,
"app metric hit its series cardinality cap; samples carrying a new label set \
are dropped. Label values must come from a small closed set — never user \
input or IDs"
);
}
}
fn canonical_key(&self, labels: &[(String, String)]) -> SeriesKey {
let mut kept: Vec<(Box<str>, Box<str>)> = Vec::with_capacity(labels.len());
for (key, value) in labels {
if !is_acceptable_label_name(key) {
self.warn_labels("invalid, reserved or over-long label name");
continue;
}
if kept.iter().any(|(existing, _)| **existing == **key) {
self.warn_labels("duplicate label name");
continue;
}
kept.push((key.as_str().into(), sanitize_label_value(value)));
}
kept.sort_by(|(a, _), (b, _)| a.cmp(b));
if kept.len() > MAX_LABELS_PER_SERIES {
kept.truncate(MAX_LABELS_PER_SERIES);
self.warn_labels("too many labels");
}
kept.into_boxed_slice()
}
fn warn_labels(&self, reason: &'static str) {
if !self.label_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
metric = %self.name,
reason,
"app metric label dropped; the sample itself is still recorded"
);
}
}
fn warn_value(&self, value: f64, reason: &'static str) {
if !self.value_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
metric = %self.name,
value,
reason,
"app metric value rejected; dropping the sample"
);
}
}
fn snapshot(&self) -> InstrumentSnapshot {
let help = {
let help = self.help.read().unwrap_or_else(PoisonError::into_inner);
help.to_string()
};
let labeled: Vec<(BTreeMap<String, String>, Arc<Series>)> = {
let series = self.series.read().unwrap_or_else(PoisonError::into_inner);
series
.iter()
.map(|(key, value)| {
let labels = key
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
(labels, Arc::clone(value))
})
.collect()
};
let mut series = Vec::with_capacity(labeled.len() + 1);
if self.unlabeled_used.load(Ordering::Relaxed) {
series.push(SeriesSnapshot {
labels: BTreeMap::new(),
value: self.unlabeled.value(&self.bounds),
});
}
for (labels, value) in labeled {
series.push(SeriesSnapshot {
labels,
value: value.value(&self.bounds),
});
}
series.sort_by(|a, b| a.labels.cmp(&b.labels));
InstrumentSnapshot {
name: self.name.to_string(),
help,
kind: self.kind,
series,
dropped_series: self.dropped.load(Ordering::Relaxed),
}
}
}
#[derive(Debug)]
enum Series {
Counter(AtomicU64),
Gauge(AtomicU64),
Histogram {
slots: Box<[AtomicU64]>,
sum_bits: AtomicU64,
},
}
impl Series {
fn new(kind: InstrumentKind, bucket_count: usize) -> Self {
match kind {
InstrumentKind::Counter => Self::Counter(AtomicU64::new(0)),
InstrumentKind::Gauge => Self::Gauge(AtomicU64::new(0)),
InstrumentKind::Histogram => Self::Histogram {
slots: (0..=bucket_count).map(|_| AtomicU64::new(0)).collect(),
sum_bits: AtomicU64::new(0),
},
}
}
fn add(&self, amount: u64) {
if let Self::Counter(total) = self {
let _ = total.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(amount))
});
}
}
fn set(&self, value: f64) {
if let Self::Gauge(bits) = self {
bits.store(value.to_bits(), Ordering::Relaxed);
}
}
fn adjust(&self, delta: f64) {
if let Self::Gauge(bits) = self {
add_f64(bits, delta);
}
}
fn observe(&self, value: f64, bounds: &[f64]) {
if let Self::Histogram { slots, sum_bits } = self {
let index = bounds.partition_point(|bound| *bound < value);
if let Some(slot) = slots.get(index) {
slot.fetch_add(1, Ordering::Relaxed);
}
add_f64(sum_bits, value);
}
}
fn value(&self, bounds: &[f64]) -> SeriesValue {
match self {
Self::Counter(total) => SeriesValue::Counter {
value: total.load(Ordering::Relaxed),
},
Self::Gauge(bits) => SeriesValue::Gauge {
value: f64::from_bits(bits.load(Ordering::Relaxed)),
},
Self::Histogram { slots, sum_bits } => {
let mut cumulative: u64 = 0;
let mut buckets = Vec::with_capacity(slots.len());
for (index, slot) in slots.iter().enumerate() {
cumulative = cumulative.saturating_add(slot.load(Ordering::Relaxed));
let le = bounds
.get(index)
.map_or_else(|| "+Inf".to_string(), |bound| format_bound(*bound));
buckets.push((le, cumulative));
}
SeriesValue::Histogram {
count: cumulative,
sum: f64::from_bits(sum_bits.load(Ordering::Relaxed)),
buckets,
}
}
}
}
}
fn add_f64(cell: &AtomicU64, delta: f64) {
let _ = cell.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |bits| {
Some(
(f64::from_bits(bits) + delta)
.clamp(f64::MIN, f64::MAX)
.to_bits(),
)
});
}
fn format_bound(bound: f64) -> String {
let magnitude = bound.abs();
if magnitude == 0.0 || (1e-4..1e6).contains(&magnitude) {
return bound.to_string();
}
let rendered = format!("{bound:e}");
let Some((mantissa, exponent)) = rendered.split_once('e') else {
return rendered;
};
let (sign, digits) = exponent
.strip_prefix('-')
.map_or(("+", exponent), |rest| ("-", rest));
format!("{mantissa}e{sign}{digits:0>2}")
}
fn is_forbidden_control(c: char) -> bool {
c.is_control()
}
fn sanitize_label_value(value: &str) -> Box<str> {
if value.len() <= MAX_LABEL_VALUE_LEN && !value.contains(is_forbidden_control) {
return value.into();
}
sanitized_chars(value, MAX_LABEL_VALUE_LEN).into_boxed_str()
}
fn sanitize_owned_label_value(value: String) -> String {
if value.len() <= MAX_LABEL_VALUE_LEN && !value.contains(is_forbidden_control) {
return value;
}
sanitized_chars(&value, MAX_LABEL_VALUE_LEN)
}
fn sanitize_help(help: &str) -> Box<str> {
if help.len() <= MAX_HELP_LEN && !help.contains(is_forbidden_control) {
return help.into();
}
sanitized_chars(help, MAX_HELP_LEN).into_boxed_str()
}
fn sanitized_chars(text: &str, limit: usize) -> String {
text.chars()
.filter(|c| !is_forbidden_control(*c))
.take(limit)
.collect()
}
fn is_acceptable_label_name(name: &str) -> bool {
name.len() <= MAX_LABEL_NAME_LEN
&& crate::actuator::is_valid_label_name(name)
&& !name.starts_with("__")
&& !RESERVED_LABEL_NAMES.contains(&name)
}
fn registration_rejection(name: &str) -> Option<&'static str> {
if name.len() > MAX_METRIC_NAME_LEN {
return Some("longer than the metric name cap");
}
if !crate::actuator::is_valid_metric_name(name) {
return Some("not a valid Prometheus metric name");
}
if name.contains(':') {
return Some("`:` is reserved for recording rules");
}
if name.starts_with("autumn_") || crate::actuator::BUILTIN_METRIC_FAMILY_NAMES.contains(&name) {
return Some("the `autumn_` namespace is reserved for built-in metrics");
}
None
}
fn occupied_names(name: &str, kind: InstrumentKind) -> Vec<String> {
let mut names = Vec::with_capacity(4);
names.push(name.to_owned());
if matches!(kind, InstrumentKind::Histogram) {
names.extend(HISTOGRAM_SUFFIXES.iter().map(|s| format!("{name}{s}")));
}
names
}
fn collides_with_registered(
registered: &HashMap<Box<str>, Arc<Instrument>>,
name: &str,
kind: InstrumentKind,
) -> bool {
occupied_names(name, kind).iter().any(|candidate| {
if registered.contains_key(candidate.as_str()) {
return true;
}
HISTOGRAM_SUFFIXES.iter().any(|suffix| {
candidate.strip_suffix(suffix).is_some_and(|stem| {
registered
.get(stem)
.is_some_and(|owner| matches!(owner.kind, InstrumentKind::Histogram))
})
})
})
}
fn take_pending_bounds(name: &str, kind: InstrumentKind) -> Box<[f64]> {
let pending = REGISTRY
.pending_buckets
.write()
.unwrap_or_else(PoisonError::into_inner)
.remove(name);
if matches!(kind, InstrumentKind::Histogram) {
pending.unwrap_or_else(|| Box::from(DEFAULT_BUCKETS))
} else {
Box::default()
}
}
fn take_pending_help(name: &str, kind: InstrumentKind) -> Result<Option<Box<str>>, InstrumentKind> {
let entry = REGISTRY
.pending_help
.write()
.unwrap_or_else(PoisonError::into_inner)
.remove(name);
match entry {
None => Ok(None),
Some((described, help)) if described == kind => Ok(Some(help)),
Some((described, _)) => Err(described),
}
}
enum Registration {
Registered(Arc<Instrument>, Option<InstrumentKind>),
Existing(Arc<Instrument>),
OverCapacity,
Collision,
}
fn instrument(name: &str, kind: InstrumentKind) -> Option<Arc<Instrument>> {
if let Some(reason) = registration_rejection(name) {
REGISTRY.warn_once(name, reason);
return None;
}
{
let registered = REGISTRY
.instruments
.read()
.unwrap_or_else(PoisonError::into_inner);
if let Some(existing) = registered.get(name) {
let existing = Arc::clone(existing);
drop(registered);
return matching_kind(existing, kind);
}
if registered.len() >= MAX_INSTRUMENTS {
drop(registered);
REGISTRY.warn_over_capacity(name);
return None;
}
if collides_with_registered(®istered, name, kind) {
drop(registered);
REGISTRY.warn_once(name, "collides with another metric's family names");
return None;
}
}
let outcome = {
let mut registered = REGISTRY
.instruments
.write()
.unwrap_or_else(PoisonError::into_inner);
let existing = registered.get(name).map(Arc::clone);
let at_capacity = registered.len() >= MAX_INSTRUMENTS;
let collides = collides_with_registered(®istered, name, kind);
let outcome = match existing {
Some(existing) => Registration::Existing(existing),
None if at_capacity => Registration::OverCapacity,
None if collides => Registration::Collision,
None => {
let bounds = take_pending_bounds(name, kind);
let fresh = Arc::new(Instrument::new(name, kind, bounds));
let discarded = match take_pending_help(name, kind) {
Ok(Some(help)) => {
*fresh.help.write().unwrap_or_else(PoisonError::into_inner) = help;
None
}
Ok(None) => None,
Err(described) => Some(described),
};
registered.insert(name.into(), Arc::clone(&fresh));
Registration::Registered(fresh, discarded)
}
};
drop(registered);
outcome
};
match outcome {
Registration::Registered(instrument, discarded_description) => {
if let Some(described) = discarded_description {
tracing::warn!(
metric = %instrument.name,
described = ?described,
registered = ?kind,
"app metric was described as a different kind before it was registered; \
the description is ignored"
);
}
Some(instrument)
}
Registration::Existing(existing) => matching_kind(existing, kind),
Registration::OverCapacity => {
REGISTRY.warn_over_capacity(name);
None
}
Registration::Collision => {
REGISTRY.warn_once(name, "collides with another metric's family names");
None
}
}
}
fn matching_kind(existing: Arc<Instrument>, kind: InstrumentKind) -> Option<Arc<Instrument>> {
if existing.kind == kind {
return Some(existing);
}
warn_kind_conflict(&existing, kind);
None
}
fn warn_kind_conflict(existing: &Instrument, requested: InstrumentKind) {
if !existing.kind_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
metric = %existing.name,
registered = ?existing.kind,
requested = ?requested,
"app metric is already registered as a different kind; recording through an inert handle"
);
}
}
pub fn counter(name: &str) -> Counter {
Counter {
instrument: instrument(name, InstrumentKind::Counter),
labels: Vec::new(),
}
}
pub fn gauge(name: &str) -> Gauge {
Gauge {
instrument: instrument(name, InstrumentKind::Gauge),
labels: Vec::new(),
}
}
pub fn histogram(name: &str) -> Histogram {
Histogram {
instrument: instrument(name, InstrumentKind::Histogram),
labels: Vec::new(),
}
}
pub fn timer(name: &str) -> Timer {
Timer {
histogram: histogram(name),
}
}
pub fn describe_counter(name: &str, help: impl Into<String>) {
describe(name, InstrumentKind::Counter, &help.into());
}
pub fn describe_gauge(name: &str, help: impl Into<String>) {
describe(name, InstrumentKind::Gauge, &help.into());
}
pub fn describe_histogram(name: &str, help: impl Into<String>) {
describe(name, InstrumentKind::Histogram, &help.into());
}
enum Description {
Recorded,
Mismatch(Arc<Instrument>),
Full,
}
fn describe(name: &str, kind: InstrumentKind, help: &str) {
if let Some(reason) = registration_rejection(name) {
REGISTRY.warn_once(name, reason);
return;
}
let help = sanitize_help(help);
let outcome = {
let registered = REGISTRY
.instruments
.read()
.unwrap_or_else(PoisonError::into_inner);
match registered.get(name) {
Some(existing) if existing.kind == kind => {
*existing
.help
.write()
.unwrap_or_else(PoisonError::into_inner) = help;
Description::Recorded
}
Some(existing) => Description::Mismatch(Arc::clone(existing)),
None => {
let mut pending = REGISTRY
.pending_help
.write()
.unwrap_or_else(PoisonError::into_inner);
if pending.len() >= MAX_INSTRUMENTS && !pending.contains_key(name) {
Description::Full
} else {
pending.insert(name.into(), (kind, help));
Description::Recorded
}
}
}
};
match outcome {
Description::Recorded => {}
Description::Mismatch(existing) => warn_kind_conflict(&existing, kind),
Description::Full => REGISTRY.warn_pending_help_full(name),
}
}
enum BucketOverride {
Stored,
AlreadyRegistered,
Full,
}
pub fn set_histogram_buckets(name: &str, upper_bounds: &[f64]) {
if let Some(reason) = registration_rejection(name) {
REGISTRY.warn_once(name, reason);
return;
}
if !are_valid_bounds(upper_bounds) {
tracing::warn!(
metric = %sanitize_for_log(name),
bounds = ?upper_bounds,
max = MAX_BUCKET_BOUNDS,
"histogram bucket bounds must be finite, positive, strictly ascending values; \
keeping the defaults"
);
return;
}
let outcome = {
let registered = REGISTRY
.instruments
.read()
.unwrap_or_else(PoisonError::into_inner);
if registered.contains_key(name) {
BucketOverride::AlreadyRegistered
} else {
let mut pending = REGISTRY
.pending_buckets
.write()
.unwrap_or_else(PoisonError::into_inner);
if pending.len() >= MAX_INSTRUMENTS && !pending.contains_key(name) {
BucketOverride::Full
} else {
pending.insert(name.into(), upper_bounds.into());
BucketOverride::Stored
}
}
};
match outcome {
BucketOverride::Stored => {}
BucketOverride::AlreadyRegistered => tracing::warn!(
metric = %sanitize_for_log(name),
"histogram bucket bounds are frozen once the histogram is registered; ignoring"
),
BucketOverride::Full => REGISTRY.warn_pending_buckets_full(name),
}
}
fn are_valid_bounds(bounds: &[f64]) -> bool {
!bounds.is_empty()
&& bounds.len() <= MAX_BUCKET_BOUNDS
&& bounds.iter().all(|b| b.is_finite() && *b > 0.0)
&& bounds.windows(2).all(|w| w[0] < w[1])
}
mod sealed {
pub trait Sealed {}
}
pub trait IntoMetricValue: sealed::Sealed {
#[must_use]
fn into_metric_value(self) -> f64;
}
macro_rules! impl_lossless_metric_value {
($($ty:ty),+ $(,)?) => {$(
impl sealed::Sealed for $ty {}
impl IntoMetricValue for $ty {
fn into_metric_value(self) -> f64 {
f64::from(self)
}
}
)+};
}
macro_rules! impl_wide_metric_value {
($($ty:ty),+ $(,)?) => {$(
impl sealed::Sealed for $ty {}
impl IntoMetricValue for $ty {
#[allow(
clippy::cast_precision_loss,
reason = "documented on IntoMetricValue: exact to 2^53, rounded above it"
)]
fn into_metric_value(self) -> f64 {
self as f64
}
}
)+};
}
impl_lossless_metric_value!(f32, f64, i8, i16, i32, u8, u16, u32);
impl_wide_metric_value!(i64, u64, isize, usize);
#[derive(Clone, Debug)]
#[must_use = "a metric handle does nothing until you record through it"]
pub struct Counter {
instrument: Option<Arc<Instrument>>,
labels: Vec<(String, String)>,
}
impl Counter {
pub fn with_label(mut self, key: &str, value: impl Into<String>) -> Self {
if self.instrument.is_some() {
self.labels
.push((key.to_owned(), sanitize_owned_label_value(value.into())));
}
self
}
pub fn increment(&self, amount: u64) {
if let Some(instrument) = self.instrument.as_ref() {
instrument.record(&self.labels, |series| series.add(amount));
}
}
}
#[derive(Clone, Debug)]
#[must_use = "a metric handle does nothing until you record through it"]
pub struct Gauge {
instrument: Option<Arc<Instrument>>,
labels: Vec<(String, String)>,
}
impl Gauge {
pub fn with_label(mut self, key: &str, value: impl Into<String>) -> Self {
if self.instrument.is_some() {
self.labels
.push((key.to_owned(), sanitize_owned_label_value(value.into())));
}
self
}
pub fn set(&self, value: impl IntoMetricValue) {
let value = value.into_metric_value();
let Some(instrument) = self.instrument.as_ref() else {
return;
};
if !value.is_finite() {
instrument.warn_value(value, "a gauge value must be finite");
return;
}
instrument.record(&self.labels, |series| series.set(value));
}
pub fn increment(&self, delta: impl IntoMetricValue) {
self.adjust(delta.into_metric_value());
}
pub fn decrement(&self, delta: impl IntoMetricValue) {
self.adjust(-delta.into_metric_value());
}
fn adjust(&self, delta: f64) {
let Some(instrument) = self.instrument.as_ref() else {
return;
};
if !delta.is_finite() {
instrument.warn_value(delta, "a gauge delta must be finite");
return;
}
instrument.record(&self.labels, |series| series.adjust(delta));
}
}
#[derive(Clone, Debug)]
#[must_use = "a metric handle does nothing until you record through it"]
pub struct Histogram {
instrument: Option<Arc<Instrument>>,
labels: Vec<(String, String)>,
}
impl Histogram {
pub fn with_label(mut self, key: &str, value: impl Into<String>) -> Self {
if self.instrument.is_some() {
self.labels
.push((key.to_owned(), sanitize_owned_label_value(value.into())));
}
self
}
pub fn record(&self, value: impl IntoMetricValue) {
let value = value.into_metric_value();
let Some(instrument) = self.instrument.as_ref() else {
return;
};
if !value.is_finite() || value < 0.0 {
instrument.warn_value(
value,
"a histogram observation must be finite and non-negative",
);
return;
}
instrument.record(&self.labels, |series| {
series.observe(value, &instrument.bounds);
});
}
}
#[derive(Clone, Debug)]
#[must_use = "a metric handle does nothing until you record through it"]
pub struct Timer {
histogram: Histogram,
}
impl Timer {
pub fn with_label(mut self, key: &str, value: impl Into<String>) -> Self {
self.histogram = self.histogram.with_label(key, value);
self
}
pub fn start(&self) -> TimerGuard {
TimerGuard {
started: Instant::now(),
timer: Some(self.clone()),
}
}
pub fn record(&self, elapsed: Duration) {
self.histogram.record(elapsed.as_secs_f64());
}
pub fn time<T>(&self, f: impl FnOnce() -> T) -> T {
let _guard = self.start();
f()
}
pub async fn time_async<F: Future>(&self, fut: F) -> F::Output {
let _guard = self.start();
fut.await
}
}
#[derive(Debug)]
#[must_use = "the timer only records for as long as the guard is alive"]
pub struct TimerGuard {
started: Instant,
timer: Option<Timer>,
}
impl TimerGuard {
pub fn cancel(mut self) {
self.timer = None;
}
#[allow(clippy::must_use_candidate)]
pub fn stop(mut self) -> Duration {
let elapsed = self.started.elapsed();
if let Some(timer) = self.timer.take() {
timer.record(elapsed);
}
elapsed
}
}
impl Drop for TimerGuard {
fn drop(&mut self) {
if let Some(timer) = self.timer.take() {
timer.record(self.started.elapsed());
}
}
}
#[must_use]
pub fn snapshot() -> Vec<InstrumentSnapshot> {
let instruments: Vec<Arc<Instrument>> = {
let registered = REGISTRY
.instruments
.read()
.unwrap_or_else(PoisonError::into_inner);
registered.values().map(Arc::clone).collect()
};
let mut snapshot: Vec<InstrumentSnapshot> = instruments.iter().map(|i| i.snapshot()).collect();
snapshot.sort_by(|a, b| a.name.cmp(&b.name));
snapshot
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum InstrumentKind {
Counter,
Gauge,
Histogram,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct InstrumentSnapshot {
pub name: String,
pub help: String,
pub kind: InstrumentKind,
pub series: Vec<SeriesSnapshot>,
pub dropped_series: u64,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct SeriesSnapshot {
pub labels: BTreeMap<String, String>,
pub value: SeriesValue,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum SeriesValue {
Counter {
value: u64,
},
Gauge {
value: f64,
},
Histogram {
count: u64,
sum: f64,
buckets: Vec<(String, u64)>,
},
}
#[cfg(any(test, feature = "test-support"))]
pub fn reset_for_tests() {
REGISTRY
.instruments
.write()
.unwrap_or_else(PoisonError::into_inner)
.clear();
REGISTRY
.pending_buckets
.write()
.unwrap_or_else(PoisonError::into_inner)
.clear();
REGISTRY
.pending_help
.write()
.unwrap_or_else(PoisonError::into_inner)
.clear();
REGISTRY
.warned_names
.write()
.unwrap_or_else(PoisonError::into_inner)
.clear();
REGISTRY
.over_capacity_warned
.store(false, Ordering::Relaxed);
REGISTRY
.pending_buckets_full_warned
.store(false, Ordering::Relaxed);
REGISTRY
.pending_help_full_warned
.store(false, Ordering::Relaxed);
}
#[cfg(any(test, feature = "test-support"))]
pub mod testing {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[must_use]
pub fn unique_name(prefix: &str) -> String {
format!("{prefix}_{}", SEQUENCE.fetch_add(1, Ordering::Relaxed))
}
}
#[cfg(test)]
mod tests {
use super::testing::unique_name;
use super::*;
fn find(name: &str) -> Option<InstrumentSnapshot> {
snapshot().into_iter().find(|i| i.name == name)
}
fn expect_instrument(name: &str) -> InstrumentSnapshot {
find(name).unwrap_or_else(|| panic!("instrument {name} missing from snapshot"))
}
fn only_series(instrument: &InstrumentSnapshot) -> &SeriesSnapshot {
assert_eq!(
instrument.series.len(),
1,
"expected exactly one series on {}",
instrument.name
);
&instrument.series[0]
}
fn counter_value(series: &SeriesSnapshot) -> u64 {
match series.value {
SeriesValue::Counter { value } => value,
ref other => panic!("expected a counter value, got {other:?}"),
}
}
fn gauge_value(series: &SeriesSnapshot) -> f64 {
match series.value {
SeriesValue::Gauge { value } => value,
ref other => panic!("expected a gauge value, got {other:?}"),
}
}
fn bucket_bounds(instrument: &InstrumentSnapshot) -> Vec<String> {
let (_count, _sum, buckets) = histogram_parts(only_series(instrument));
buckets
.into_iter()
.map(|(le, _)| le)
.filter(|le| le != "+Inf")
.collect()
}
fn default_bucket_strings() -> Vec<String> {
DEFAULT_BUCKETS.iter().map(|b| format_bound(*b)).collect()
}
fn histogram_parts(series: &SeriesSnapshot) -> (u64, f64, Vec<(String, u64)>) {
match series.value {
SeriesValue::Histogram {
count,
sum,
ref buckets,
} => (count, sum, buckets.clone()),
ref other => panic!("expected a histogram value, got {other:?}"),
}
}
#[test]
fn counter_increment_accumulates_in_snapshot() {
let name = unique_name("facade_counter_accumulates");
counter(&name).increment(1);
counter(&name).increment(1);
counter(&name).increment(1);
let instrument = expect_instrument(&name);
assert_eq!(instrument.kind, InstrumentKind::Counter);
assert_eq!(counter_value(only_series(&instrument)), 3);
}
#[test]
fn counter_increment_by_amount_accumulates() {
let name = unique_name("facade_counter_by_amount");
let handle = counter(&name);
handle.increment(7);
handle.increment(35);
assert_eq!(counter_value(only_series(&expect_instrument(&name))), 42);
}
#[test]
fn counter_help_text_is_exposed_in_snapshot() {
let name = unique_name("facade_counter_help");
describe_counter(&name, "how many widgets shipped");
counter(&name).increment(1);
assert_eq!(expect_instrument(&name).help, "how many widgets shipped");
}
#[test]
fn label_order_canonicalizes_to_a_single_series() {
let name = unique_name("facade_label_order");
counter(&name)
.with_label("region", "eu")
.with_label("status", "paid")
.increment(1);
counter(&name)
.with_label("status", "paid")
.with_label("region", "eu")
.increment(1);
let instrument = expect_instrument(&name);
assert_eq!(
instrument.series.len(),
1,
"label order must not create a second series: {:?}",
instrument.series
);
let series = only_series(&instrument);
assert_eq!(counter_value(series), 2);
assert_eq!(series.labels.get("region").map(String::as_str), Some("eu"));
assert_eq!(
series.labels.get("status").map(String::as_str),
Some("paid")
);
}
#[test]
fn duplicate_label_key_keeps_the_first_value() {
let name = unique_name("facade_label_dup");
counter(&name)
.with_label("status", "paid")
.with_label("status", "refunded")
.increment(1);
let instrument = expect_instrument(&name);
let series = only_series(&instrument);
assert_eq!(
series.labels.get("status").map(String::as_str),
Some("paid")
);
assert_eq!(series.labels.len(), 1);
}
#[test]
fn invalid_label_name_drops_the_label_not_the_sample() {
let name = unique_name("facade_label_invalid");
counter(&name)
.with_label("not-a-valid-name", "x")
.with_label("status", "paid")
.increment(1);
let instrument = expect_instrument(&name);
let series = only_series(&instrument);
assert_eq!(
counter_value(series),
1,
"the sample must still be recorded"
);
assert!(
!series.labels.contains_key("not-a-valid-name"),
"invalid label must be dropped: {:?}",
series.labels
);
assert_eq!(
series.labels.get("status").map(String::as_str),
Some("paid")
);
}
#[test]
fn reserved_label_names_are_dropped() {
let name = unique_name("facade_label_reserved");
counter(&name)
.with_label("le", "0.5")
.with_label("quantile", "0.99")
.with_label("__private", "x")
.with_label("status", "paid")
.increment(1);
let instrument = expect_instrument(&name);
let series = only_series(&instrument);
assert_eq!(counter_value(series), 1);
assert_eq!(
series.labels.len(),
1,
"only `status` survives: {:?}",
series.labels
);
assert_eq!(
series.labels.get("status").map(String::as_str),
Some("paid")
);
}
#[test]
fn long_label_values_are_truncated() {
let name = unique_name("facade_label_truncate");
let long = "x".repeat(MAX_LABEL_VALUE_LEN * 2);
counter(&name).with_label("status", long).increment(1);
let instrument = expect_instrument(&name);
let series = only_series(&instrument);
assert_eq!(
series.labels.get("status").map(String::len),
Some(MAX_LABEL_VALUE_LEN)
);
}
#[test]
fn gauge_set_increment_and_decrement_track_the_current_value() {
let name = unique_name("facade_gauge_basic");
let handle = gauge(&name);
handle.set(10.0);
handle.increment(5.0);
handle.decrement(2.5);
let instrument = expect_instrument(&name);
assert_eq!(instrument.kind, InstrumentKind::Gauge);
assert!(
(gauge_value(only_series(&instrument)) - 12.5).abs() < f64::EPSILON,
"expected 12.5, got {}",
gauge_value(only_series(&instrument))
);
}
#[test]
fn gauge_concurrent_increments_sum_exactly() {
const THREADS: usize = 8;
const PER_THREAD: usize = 500;
let name = unique_name("facade_gauge_concurrent");
let handle = gauge(&name);
std::thread::scope(|scope| {
for _ in 0..THREADS {
let handle = handle.clone();
scope.spawn(move || {
for _ in 0..PER_THREAD {
handle.increment(1.0);
}
});
}
});
let instrument = expect_instrument(&name);
#[allow(clippy::cast_precision_loss)]
let expected = (THREADS * PER_THREAD) as f64;
assert!(
(gauge_value(only_series(&instrument)) - expected).abs() < f64::EPSILON,
"lost updates: expected {expected}, got {}",
gauge_value(only_series(&instrument))
);
}
#[test]
fn histogram_record_fills_cumulative_buckets() {
let name = unique_name("facade_histogram_buckets");
let handle = histogram(&name);
handle.record(0.003);
handle.record(0.03);
handle.record(100.0);
let instrument = expect_instrument(&name);
assert_eq!(instrument.kind, InstrumentKind::Histogram);
let (count, sum, buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 3);
assert!(
(sum - 100.033).abs() < 1e-9,
"sum must be the exact total, got {sum}"
);
assert_eq!(
buckets,
vec![
("0.005".to_string(), 1),
("0.01".to_string(), 1),
("0.025".to_string(), 1),
("0.05".to_string(), 2),
("0.1".to_string(), 2),
("0.25".to_string(), 2),
("0.5".to_string(), 2),
("1".to_string(), 2),
("2.5".to_string(), 2),
("5".to_string(), 2),
("10".to_string(), 2),
("+Inf".to_string(), 3),
],
"buckets must be cumulative with canonical `le` strings"
);
}
#[test]
fn histogram_inf_bucket_equals_count_and_buckets_never_decrease() {
let name = unique_name("facade_histogram_invariants");
let handle = histogram(&name);
for value in [0.001, 0.2, 0.75, 3.0, 42.0] {
handle.record(value);
}
let instrument = expect_instrument(&name);
let (count, _sum, buckets) = histogram_parts(only_series(&instrument));
let (last_le, last_value) = buckets.last().expect("at least the +Inf bucket");
assert_eq!(last_le, "+Inf", "the final bucket must be +Inf");
assert_eq!(*last_value, count, "+Inf must equal the derived count");
assert_eq!(count, 5);
assert!(
buckets.windows(2).all(|w| w[0].1 <= w[1].1),
"cumulative buckets must be non-decreasing: {buckets:?}"
);
}
#[test]
fn histogram_rejects_non_finite_and_negative_observations() {
let name = unique_name("facade_histogram_rejects");
let handle = histogram(&name);
handle.record(1.0);
handle.record(f64::NAN);
handle.record(f64::INFINITY);
handle.record(f64::NEG_INFINITY);
handle.record(-1.0);
let instrument = expect_instrument(&name);
let (count, sum, buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 1, "only the one valid observation counts");
assert!(sum.is_finite(), "sum must never become NaN or infinite");
assert!(
(sum - 1.0).abs() < f64::EPSILON,
"sum should be 1.0, got {sum}"
);
assert_eq!(buckets.last().map(|(_, v)| *v), Some(1));
}
#[test]
fn histogram_concurrent_records_are_exact() {
const THREADS: usize = 8;
const PER_THREAD: usize = 250;
let name = unique_name("facade_histogram_concurrent");
let handle = histogram(&name);
std::thread::scope(|scope| {
for _ in 0..THREADS {
let handle = handle.clone();
scope.spawn(move || {
for _ in 0..PER_THREAD {
handle.record(0.02);
}
});
}
});
let instrument = expect_instrument(&name);
let (count, _sum, buckets) = histogram_parts(only_series(&instrument));
assert_eq!(usize::try_from(count).unwrap(), THREADS * PER_THREAD);
assert_eq!(buckets.last().map(|(_, v)| *v), Some(count));
}
#[test]
fn timer_guard_records_on_drop() {
let name = unique_name("facade_timer_drop_seconds");
{
let _guard = timer(&name).start();
}
let instrument = expect_instrument(&name);
let (count, sum, _buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 1);
assert!(sum >= 0.0 && sum.is_finite(), "sum must be sane, got {sum}");
}
#[test]
fn timer_guard_records_on_the_error_path() {
fn fallible(t: &Timer) -> Result<(), &'static str> {
let _guard = t.start();
Err("boom")?;
Ok(())
}
let name = unique_name("facade_timer_error_seconds");
let t = timer(&name);
assert!(fallible(&t).is_err());
let instrument = expect_instrument(&name);
let (count, _sum, _buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 1, "an early `?` return must still record");
}
#[test]
fn timer_guard_records_when_dropped_during_panic_unwind() {
let name = unique_name("facade_timer_unwind_seconds");
let t = timer(&name);
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = t.start();
panic!("handler blew up");
}));
assert!(unwound.is_err(), "the closure must actually panic");
let instrument = expect_instrument(&name);
let (count, _sum, _buckets) = histogram_parts(only_series(&instrument));
assert_eq!(
count, 1,
"a guard dropped while unwinding must still record"
);
}
#[test]
fn timer_time_records_the_closure_duration() {
let name = unique_name("facade_timer_time_seconds");
let value = timer(&name).time(|| 21 * 2);
assert_eq!(value, 42);
let instrument = expect_instrument(&name);
let (count, _sum, _buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 1);
}
#[tokio::test]
async fn timer_time_async_records_the_future_duration() {
let name = unique_name("facade_timer_async_seconds");
let value = timer(&name).time_async(async { 42 }).await;
assert_eq!(value, 42);
let instrument = expect_instrument(&name);
let (count, _sum, _buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 1);
}
#[test]
fn timer_guard_cancel_discards_the_measurement() {
let name = unique_name("facade_timer_cancel_seconds");
let t = timer(&name);
t.start().cancel();
assert!(
find(&name).is_none_or(|i| i.series.is_empty()),
"a cancelled guard must record nothing"
);
}
#[test]
fn timer_guard_stop_returns_elapsed_and_records_once() {
let name = unique_name("facade_timer_stop_seconds");
let elapsed = timer(&name).start().stop();
assert!(elapsed < Duration::from_secs(60), "elapsed looks wrong");
let instrument = expect_instrument(&name);
let (count, _sum, _buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 1, "stop() must record exactly once, not twice");
}
#[test]
fn timer_record_accepts_a_measured_duration() {
let name = unique_name("facade_timer_record_seconds");
timer(&name).record(Duration::from_millis(250));
let instrument = expect_instrument(&name);
let (count, sum, _buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 1);
assert!(
(sum - 0.25).abs() < 1e-6,
"timers record seconds; got {sum}"
);
}
#[test]
fn cardinality_cap_drops_series_beyond_the_limit() {
let name = unique_name("facade_cardinality_cap");
for i in 0..=MAX_SERIES_PER_METRIC {
counter(&name)
.with_label("shard", i.to_string())
.increment(1);
}
let instrument = expect_instrument(&name);
assert_eq!(
instrument.series.len(),
MAX_SERIES_PER_METRIC,
"the cap must hold the series count at {MAX_SERIES_PER_METRIC}"
);
assert_eq!(
instrument.dropped_series, 1,
"the one over-cap label set must be counted as dropped"
);
}
#[test]
fn dropped_series_counts_samples_not_distinct_label_sets() {
let name = unique_name("facade_dropped_counts_samples");
for i in 0..MAX_SERIES_PER_METRIC {
counter(&name)
.with_label("shard", i.to_string())
.increment(1);
}
for _ in 0..3 {
counter(&name).with_label("shard", "over-cap").increment(1);
}
let instrument = expect_instrument(&name);
assert_eq!(instrument.series.len(), MAX_SERIES_PER_METRIC);
assert_eq!(
instrument.dropped_series, 3,
"three samples for one over-cap label set must count as three"
);
}
#[test]
fn label_count_beyond_the_cap_is_dropped_not_the_sample() {
let name = unique_name("facade_label_cap");
let mut handle = counter(&name);
for i in 0..=MAX_LABELS_PER_SERIES {
handle = handle.with_label(&format!("k{i}"), i.to_string());
}
handle.increment(1);
let instrument = expect_instrument(&name);
let series = only_series(&instrument);
assert_eq!(counter_value(series), 1, "the sample is still recorded");
assert_eq!(series.labels.len(), MAX_LABELS_PER_SERIES);
}
#[test]
fn label_subset_beyond_the_cap_does_not_depend_on_call_order() {
let name = unique_name("facade_label_cap_order");
let keys: Vec<String> = (0..10).map(|i| format!("k{i}")).collect();
let mut ascending = counter(&name);
for key in &keys {
ascending = ascending.with_label(key, "v");
}
ascending.increment(1);
let mut descending = counter(&name);
for key in keys.iter().rev() {
descending = descending.with_label(key, "v");
}
descending.increment(1);
let instrument = expect_instrument(&name);
assert_eq!(
instrument.series.len(),
1,
"label order must not split the sample across two series: {:?}",
instrument.series
);
let series = only_series(&instrument);
assert_eq!(counter_value(series), 2);
let kept: Vec<&str> = series.labels.keys().map(String::as_str).collect();
assert_eq!(
kept,
vec!["k0", "k1", "k2", "k3", "k4", "k5", "k6", "k7"],
"the retained subset is the lexicographically smallest {MAX_LABELS_PER_SERIES}"
);
}
#[test]
fn kind_conflict_yields_an_inert_handle_and_one_instrument() {
let name = unique_name("facade_kind_conflict");
counter(&name).increment(1);
gauge(&name).set(99.0);
let matching: Vec<_> = snapshot().into_iter().filter(|i| i.name == name).collect();
assert_eq!(
matching.len(),
1,
"first registration wins: exactly one instrument per name"
);
assert_eq!(matching[0].kind, InstrumentKind::Counter);
assert_eq!(
counter_value(only_series(&matching[0])),
1,
"the conflicting gauge write must be a no-op"
);
}
#[test]
fn autumn_prefixed_names_are_rejected() {
let name = format!("autumn_{}", unique_name("facade_reserved"));
counter(&name).increment(1);
assert!(
find(&name).is_none(),
"the `autumn_` namespace is reserved for framework metrics"
);
}
#[test]
fn builtin_metric_family_names_are_rejected() {
counter("autumn_http_requests_total").increment(1);
gauge("autumn_http_requests_active").set(1.0);
assert!(
snapshot()
.iter()
.all(|i| !i.name.starts_with("autumn_http_")),
"built-in family names must never be registrable through the facade"
);
}
#[test]
fn invalid_metric_names_are_rejected() {
for name in ["", "0leading_digit", "has-hyphen", "has.dot", "has space"] {
counter(name).increment(1);
assert!(
find(name).is_none(),
"invalid metric name {name:?} must yield an inert handle"
);
}
}
#[test]
fn colon_in_metric_name_is_rejected() {
let name = format!("ns:{}", unique_name("facade_colon"));
counter(&name).increment(1);
assert!(find(&name).is_none());
}
#[test]
fn counter_colliding_with_an_existing_histograms_derived_name_is_rejected() {
let base = unique_name("facade_collide_forward");
histogram(&base).record(1.0);
let derived = format!("{base}_count");
counter(&derived).increment(1);
assert!(
find(&derived).is_none(),
"`{derived}` is the histogram's derived count family"
);
let bucket = format!("{base}_bucket");
counter(&bucket).increment(1);
assert!(find(&bucket).is_none());
let sum = format!("{base}_sum");
gauge(&sum).set(1.0);
assert!(find(&sum).is_none());
}
#[test]
fn histogram_colliding_with_an_existing_derived_name_is_rejected() {
let base = unique_name("facade_collide_reverse");
counter(&format!("{base}_bucket")).increment(1);
histogram(&base).record(1.0);
assert!(
find(&base).is_none(),
"the histogram would emit `{base}_bucket`, which already exists"
);
}
#[test]
fn set_histogram_buckets_before_the_first_record_applies() {
let name = unique_name("facade_buckets_before");
set_histogram_buckets(&name, &[1.0, 2.0, 3.0]);
histogram(&name).record(2.5);
let instrument = expect_instrument(&name);
assert_eq!(bucket_bounds(&instrument), vec!["1", "2", "3"]);
let (_count, _sum, buckets) = histogram_parts(only_series(&instrument));
assert_eq!(
buckets,
vec![
("1".to_string(), 0),
("2".to_string(), 0),
("3".to_string(), 1),
("+Inf".to_string(), 1),
]
);
}
#[test]
fn set_histogram_buckets_after_registration_is_ignored() {
let name = unique_name("facade_buckets_after");
histogram(&name).record(0.03);
set_histogram_buckets(&name, &[1.0, 2.0, 3.0]);
assert_eq!(
bucket_bounds(&expect_instrument(&name)),
default_bucket_strings(),
"bucket boundaries must not move once a scrape target has seen them"
);
}
#[test]
fn set_histogram_buckets_rejects_invalid_bounds() {
for bounds in [
vec![],
vec![3.0, 2.0, 1.0],
vec![1.0, 1.0],
vec![0.0, 1.0],
vec![-1.0, 1.0],
vec![f64::NAN],
vec![f64::INFINITY],
(1..=21).map(f64::from).collect::<Vec<_>>(),
] {
let name = unique_name("facade_buckets_invalid");
set_histogram_buckets(&name, &bounds);
histogram(&name).record(1.0);
assert_eq!(
bucket_bounds(&expect_instrument(&name)),
default_bucket_strings(),
"invalid bounds {bounds:?} must be ignored in favour of the defaults"
);
}
}
#[test]
fn describe_histogram_then_set_histogram_buckets_keeps_both() {
let name = unique_name("facade_describe_then_buckets");
describe_histogram(&name, "how long a thing took");
set_histogram_buckets(&name, &[1.0, 2.0, 3.0]);
histogram(&name).record(2.5);
let instrument = expect_instrument(&name);
assert_eq!(instrument.help, "how long a thing took");
assert_eq!(
bucket_bounds(&instrument),
vec!["1", "2", "3"],
"the custom bounds must survive an earlier describe_histogram"
);
}
#[test]
fn set_histogram_buckets_then_describe_histogram_keeps_both() {
let name = unique_name("facade_buckets_then_describe");
set_histogram_buckets(&name, &[1.0, 2.0, 3.0]);
describe_histogram(&name, "how long a thing took");
histogram(&name).record(2.5);
let instrument = expect_instrument(&name);
assert_eq!(instrument.help, "how long a thing took");
assert_eq!(bucket_bounds(&instrument), vec!["1", "2", "3"]);
}
#[test]
fn describe_alone_does_not_register_the_instrument() {
let name = unique_name("facade_describe_only");
describe_counter(&name, "never actually used");
assert!(
find(&name).is_none(),
"describing a metric must not register it — a described-but-unused \
metric stays out of the scrape entirely"
);
}
#[test]
fn describe_after_registration_still_applies() {
let name = unique_name("facade_describe_after");
counter(&name).increment(1);
describe_counter(&name, "described late");
assert_eq!(expect_instrument(&name).help, "described late");
}
#[test]
fn describing_as_the_wrong_kind_does_not_apply_the_help() {
let name = unique_name("facade_describe_wrong_kind");
describe_gauge(&name, "described as a gauge");
counter(&name).increment(1);
let instrument = expect_instrument(&name);
assert_eq!(instrument.kind, InstrumentKind::Counter);
assert_eq!(
instrument.help, "",
"a description written for another kind must not be applied"
);
}
#[test]
fn help_text_is_stripped_of_control_characters_and_truncated() {
let name = unique_name("facade_help_sanitized");
let help = format!("first\nsecond\x1b[31m{}", "x".repeat(MAX_HELP_LEN));
describe_counter(&name, help);
counter(&name).increment(1);
let help = expect_instrument(&name).help;
assert!(
!help.contains('\n') && !help.contains('\x1b'),
"control characters must not survive into a HELP line: {help:?}"
);
assert_eq!(
help.chars().count(),
MAX_HELP_LEN,
"help must be truncated to the cap"
);
assert!(help.starts_with("firstsecond[31m"));
}
#[test]
fn snapshot_is_sorted_by_instrument_name() {
let base = unique_name("facade_sorted");
counter(&format!("{base}_zulu")).increment(1);
counter(&format!("{base}_alpha")).increment(1);
gauge(&format!("{base}_mike")).set(1.0);
let names: Vec<String> = snapshot().into_iter().map(|i| i.name).collect();
assert!(
names.windows(2).all(|w| w[0] <= w[1]),
"snapshot must be sorted by name: {names:?}"
);
let ours: Vec<&String> = names.iter().filter(|n| n.starts_with(&base)).collect();
assert_eq!(
ours,
vec![
&format!("{base}_alpha"),
&format!("{base}_mike"),
&format!("{base}_zulu")
]
);
}
#[test]
fn snapshot_series_are_sorted_by_canonical_label_key() {
let name = unique_name("facade_series_sorted");
for shard in ["c", "a", "b"] {
counter(&name).with_label("shard", shard).increment(1);
}
let instrument = expect_instrument(&name);
let shards: Vec<&str> = instrument
.series
.iter()
.map(|s| s.labels.get("shard").map_or("", String::as_str))
.collect();
assert_eq!(shards, vec!["a", "b", "c"]);
}
#[test]
fn over_long_metric_names_are_rejected_not_truncated() {
let stem = unique_name("facade_long_name");
let name = format!("{stem}_{}", "x".repeat(MAX_METRIC_NAME_LEN));
assert!(name.len() > MAX_METRIC_NAME_LEN);
counter(&name).increment(1);
assert!(find(&name).is_none(), "an over-long name must be rejected");
assert!(
snapshot().iter().all(|i| !i.name.starts_with(&stem)),
"a rejected name must not be truncated into a registration either"
);
}
#[test]
fn metric_names_at_the_cap_are_accepted() {
let stem = unique_name("facade_cap_name");
let name = format!("{stem}{}", "x".repeat(MAX_METRIC_NAME_LEN - stem.len()));
assert_eq!(name.len(), MAX_METRIC_NAME_LEN);
counter(&name).increment(1);
assert_eq!(counter_value(only_series(&expect_instrument(&name))), 1);
}
#[test]
fn over_long_label_names_are_dropped_not_the_sample() {
let name = unique_name("facade_long_label_name");
let long_key = "k".repeat(MAX_LABEL_NAME_LEN + 1);
counter(&name)
.with_label(&long_key, "x")
.with_label("status", "paid")
.increment(1);
let instrument = expect_instrument(&name);
let series = only_series(&instrument);
assert_eq!(
counter_value(series),
1,
"the sample must still be recorded"
);
assert_eq!(
series.labels.len(),
1,
"only `status` survives: {:?}",
series.labels
);
}
#[test]
fn control_characters_are_stripped_from_label_values() {
let name = unique_name("facade_label_control_chars");
counter(&name)
.with_label("status", "pa\rid\x1b[31m\nnext")
.increment(1);
let instrument = expect_instrument(&name);
let value = only_series(&instrument)
.labels
.get("status")
.expect("the label survives")
.clone();
assert_eq!(
value, "paid[31mnext",
"C0 control characters must not reach the exposition format"
);
}
#[test]
fn sanitize_for_log_escapes_and_truncates() {
let injected = sanitize_for_log("evil\nWARN forged log line");
assert!(
!injected.contains('\n'),
"a rejected name must not be able to forge a log line: {injected}"
);
assert_eq!(injected, "evil\\nWARN forged log line");
let long = sanitize_for_log(&"x".repeat(LOG_NAME_PREVIEW_LEN * 4));
assert_eq!(long.chars().count(), LOG_NAME_PREVIEW_LEN + 1);
assert!(long.ends_with('…'));
}
#[test]
fn gauge_rejects_non_finite_values() {
let name = unique_name("facade_gauge_non_finite");
let handle = gauge(&name);
handle.set(7.0);
handle.set(f64::NAN);
handle.set(f64::INFINITY);
handle.set(f64::NEG_INFINITY);
handle.increment(f64::NAN);
handle.decrement(f64::INFINITY);
let instrument = expect_instrument(&name);
let value = gauge_value(only_series(&instrument));
assert!(
(value - 7.0).abs() < f64::EPSILON,
"a rejected value must leave the gauge untouched, got {value}"
);
let json = serde_json::to_value(&instrument).unwrap();
assert_eq!(json["series"][0]["value"]["value"].as_f64(), Some(7.0));
}
#[test]
fn gauge_adjustments_saturate_instead_of_overflowing_to_infinity() {
let name = unique_name("facade_gauge_saturating");
let handle = gauge(&name);
handle.set(f64::MAX);
handle.increment(f64::MAX);
let instrument = expect_instrument(&name);
let value = gauge_value(only_series(&instrument));
assert!(
value.is_finite(),
"a finite adjustment must never store a non-finite gauge, got {value}"
);
assert!((value - f64::MAX).abs() < f64::EPSILON * f64::MAX);
handle.set(f64::MIN);
handle.decrement(f64::MAX);
let instrument = expect_instrument(&name);
let value = gauge_value(only_series(&instrument));
assert!(value.is_finite(), "saturation must hold downward too");
}
#[test]
fn histogram_sum_saturates_instead_of_overflowing_to_infinity() {
let name = unique_name("facade_histogram_sum_saturating");
let handle = histogram(&name);
handle.record(f64::MAX);
handle.record(f64::MAX);
let instrument = expect_instrument(&name);
let (count, sum, _buckets) = histogram_parts(only_series(&instrument));
assert_eq!(count, 2);
assert!(
sum.is_finite(),
"_sum must saturate at f64::MAX, not poison to +Inf, got {sum}"
);
}
#[test]
fn gauge_and_histogram_accept_integer_types() {
let queue: Vec<u8> = vec![1, 2, 3];
let gauge_name = unique_name("facade_integer_gauge");
gauge(&gauge_name).set(queue.len()); gauge(&gauge_name).increment(2_u64);
gauge(&gauge_name).decrement(1_i64);
let value = gauge_value(only_series(&expect_instrument(&gauge_name)));
assert!((value - 4.0).abs() < f64::EPSILON, "got {value}");
let hist_name = unique_name("facade_integer_histogram");
histogram(&hist_name).record(2_048_usize);
let (count, sum, _buckets) = histogram_parts(only_series(&expect_instrument(&hist_name)));
assert_eq!(count, 1);
assert!((sum - 2048.0).abs() < f64::EPSILON, "got {sum}");
}
#[test]
fn counter_saturates_instead_of_wrapping() {
let name = unique_name("facade_counter_saturates");
let handle = counter(&name);
handle.increment(u64::MAX);
handle.increment(3);
assert_eq!(
counter_value(only_series(&expect_instrument(&name))),
u64::MAX,
"a wrapped counter would look like a reset and blow up rate()"
);
}
#[test]
fn format_bound_matches_go_g_formatting() {
assert_eq!(format_bound(0.000_05), "5e-05");
assert_eq!(format_bound(0.000_025), "2.5e-05");
assert_eq!(format_bound(1e6), "1e+06");
assert_eq!(format_bound(2_500_000.0), "2.5e+06");
assert_eq!(format_bound(1e20), "1e+20");
assert_eq!(format_bound(1e21), "1e+21");
assert_eq!(format_bound(1.5e22), "1.5e+22");
assert_eq!(format_bound(1e-7), "1e-07");
assert_eq!(format_bound(1e-100), "1e-100");
assert_eq!(format_bound(0.000_1), "0.0001");
assert_eq!(format_bound(999_999.0), "999999");
}
#[test]
fn default_bucket_strings_are_unchanged() {
assert_eq!(
default_bucket_strings(),
vec![
"0.005", "0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1", "2.5", "5", "10"
],
"the default `le` strings are a scrape-visible contract"
);
}
#[test]
fn tiny_and_huge_bounds_render_in_exponential_form() {
let name = unique_name("facade_exponential_bounds");
set_histogram_buckets(&name, &[0.000_025, 0.000_05, 1e21]);
histogram(&name).record(1.0);
assert_eq!(
bucket_bounds(&expect_instrument(&name)),
vec!["2.5e-05", "5e-05", "1e+21"]
);
}
#[test]
fn unique_name_produces_distinct_valid_metric_names() {
let a = unique_name("facade_unique");
let b = unique_name("facade_unique");
assert_ne!(a, b);
for name in [&a, &b] {
let mut chars = name.chars();
assert!(matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_'));
assert!(chars.all(|c| c.is_ascii_alphanumeric() || c == '_'));
}
}
}