const LEGACY_CONTENT_TYPE: &str = "application/openmetrics-text; version=1.0.0; charset=utf-8";
#[cfg(feature = "foundations-metrics-backend")]
use foundations_metrics::{OPENMETRICS_CONTENT_TYPE, PROTOBUF_CONTENT_TYPE};
#[cfg(not(feature = "foundations-metrics-backend"))]
const OPENMETRICS_CONTENT_TYPE: &str =
"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8";
#[cfg(not(feature = "foundations-metrics-backend"))]
const PROTOBUF_CONTENT_TYPE: &str =
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited";
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScrapeFormat {
Protobuf,
Text {
utf8_names: bool,
},
}
impl ScrapeFormat {
pub const fn fallback() -> Self {
Self::Text { utf8_names: false }
}
pub fn content_type(self) -> &'static str {
match self {
Self::Protobuf => PROTOBUF_CONTENT_TYPE,
Self::Text { utf8_names: true } => OPENMETRICS_CONTENT_TYPE,
Self::Text { utf8_names: false } => LEGACY_CONTENT_TYPE,
}
}
}
pub fn negotiate(accept: Option<&str>, allow_protobuf: bool) -> Option<ScrapeFormat> {
let Some(accept) = accept else {
return Some(ScrapeFormat::fallback());
};
let mut best: Option<(f32, ScrapeFormat)> = None;
for range in accept.split(',') {
let mut parts = range.split(';').map(str::trim);
let Some(media_type) = parts.next().filter(|media| !media.is_empty()) else {
continue;
};
let mut quality = 1.0f32;
let (mut escaping, mut proto, mut encoding) = (None, None, None);
for parameter in parts {
let Some((name, value)) = parameter.split_once('=') else {
continue;
};
let value = value.trim().trim_matches('"');
let name = name.trim();
if name.eq_ignore_ascii_case("q") {
quality = match value.parse::<f32>() {
Ok(parsed) if (0.0..=1.0).contains(&parsed) => parsed,
_ => 0.0,
};
} else if name.eq_ignore_ascii_case("escaping") {
escaping = Some(value);
} else if name.eq_ignore_ascii_case("proto") {
proto = Some(value);
} else if name.eq_ignore_ascii_case("encoding") {
encoding = Some(value);
}
}
if quality <= 0.0 {
continue;
}
let format = if media_type.eq_ignore_ascii_case("application/vnd.google.protobuf") {
if allow_protobuf
&& proto.is_some_and(|proto| proto == "io.prometheus.client.MetricFamily")
&& encoding.is_some_and(|encoding| encoding.eq_ignore_ascii_case("delimited"))
{
ScrapeFormat::Protobuf
} else {
continue;
}
} else if media_type.eq_ignore_ascii_case("application/openmetrics-text") {
ScrapeFormat::Text {
utf8_names: escaping
.is_some_and(|escaping| escaping.eq_ignore_ascii_case("allow-utf-8")),
}
} else if media_type.eq_ignore_ascii_case("text/plain") || media_type == "*/*" {
ScrapeFormat::Text { utf8_names: false }
} else {
continue;
};
if best.is_none_or(|(best_quality, _)| quality > best_quality) {
best = Some((quality, format));
}
}
best.map(|(_, format)| format)
}
pub fn negotiate_or_fallback(accept: Option<&str>, allow_protobuf: bool) -> ScrapeFormat {
negotiate(accept, allow_protobuf).unwrap_or_else(|| {
let fallback = ScrapeFormat::fallback();
report_unsatisfiable_accept(accept.unwrap_or_default(), fallback.content_type());
fallback
})
}
fn report_unsatisfiable_accept(accept: &str, served: &str) {
#[cfg(feature = "logging")]
crate::telemetry::log::warn!(
"no requested metrics format can be served, responding with the fallback instead";
"accept" => accept,
"served" => served,
);
#[cfg(not(feature = "logging"))]
eprintln!(
"no requested metrics format can be served, responding with the fallback instead: \
accept={accept:?} served={served:?}"
);
}
#[cfg(test)]
mod tests {
use super::*;
const TEXT: Option<ScrapeFormat> = Some(ScrapeFormat::Text { utf8_names: false });
const TEXT_UTF8: Option<ScrapeFormat> = Some(ScrapeFormat::Text { utf8_names: true });
const PROTOBUF: Option<ScrapeFormat> = Some(ScrapeFormat::Protobuf);
const PROMETHEUS_DEFAULT: &str = "application/openmetrics-text;version=1.0.0;q=0.5,\
text/plain;version=0.0.4;q=0.4,*/*;q=0.1";
const PROTOBUF_PREFERRED: &str = "application/vnd.google.protobuf;\
proto=io.prometheus.client.MetricFamily;\
encoding=delimited;q=0.5,\
application/openmetrics-text;version=1.0.0;q=0.4";
const PROTOBUF_ONLY: &str = "application/vnd.google.protobuf;\
proto=io.prometheus.client.MetricFamily;encoding=delimited";
#[test]
fn absent_header_falls_back_to_legacy_text() {
assert_eq!(negotiate(None, true), TEXT);
}
#[test]
fn prometheus_default_accept_selects_text() {
assert_eq!(negotiate(Some(PROMETHEUS_DEFAULT), true), TEXT);
}
#[test]
fn utf8_escaping_is_detected() {
let accept =
"application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,*/*;q=0.1";
assert_eq!(negotiate(Some(accept), true), TEXT_UTF8);
}
#[test]
fn delimited_protobuf_wins_when_preferred() {
assert_eq!(negotiate(Some(PROTOBUF_PREFERRED), true), PROTOBUF);
}
#[test]
fn protobuf_without_delimited_encoding_is_not_offered() {
let accept = "application/vnd.google.protobuf;\
proto=io.prometheus.client.MetricFamily;q=0.9,text/plain;q=0.1";
assert_eq!(negotiate(Some(accept), true), TEXT);
}
#[test]
fn zero_quality_refuses_a_format() {
let accept = "application/openmetrics-text;escaping=allow-utf-8;q=0,text/plain;q=0.4";
assert_eq!(negotiate(Some(accept), true), TEXT);
}
#[test]
fn zero_quality_on_the_only_range_matches_nothing() {
assert_eq!(
negotiate(Some("application/openmetrics-text;q=0"), true),
None
);
}
#[test]
fn malformed_quality_refuses_a_format() {
let accept = "application/openmetrics-text;escaping=allow-utf-8;q=garbage,text/plain;q=0.4";
assert_eq!(negotiate(Some(accept), true), TEXT);
}
#[test]
fn parameters_tolerate_whitespace_case_and_quoting() {
let accept =
" APPLICATION/OpenMetrics-Text ; Version=1.0.0 ; Escaping=\"Allow-UTF-8\" ; Q=0.7 ";
assert_eq!(negotiate(Some(accept), true), TEXT_UTF8);
}
#[test]
fn ties_keep_the_earliest_listed() {
let accept = "application/openmetrics-text;escaping=allow-utf-8;q=0.5,text/plain;q=0.5";
assert_eq!(negotiate(Some(accept), true), TEXT_UTF8);
}
#[test]
fn protobuf_is_withheld_when_unavailable() {
assert_eq!(negotiate(Some(PROTOBUF_PREFERRED), false), TEXT);
}
#[test]
fn withholding_protobuf_leaves_text_negotiation_untouched() {
let utf8 = "application/openmetrics-text;escaping=allow-utf-8;q=0.9,text/plain;q=0.1";
assert_eq!(negotiate(Some(utf8), false), TEXT_UTF8);
assert_eq!(negotiate(Some(PROMETHEUS_DEFAULT), false), TEXT);
assert_eq!(negotiate(None, false), TEXT);
}
#[test]
fn protobuf_only_matches_nothing_when_unavailable() {
assert_eq!(negotiate(Some(PROTOBUF_ONLY), false), None);
}
#[test]
fn protobuf_only_is_served_when_available() {
assert_eq!(negotiate(Some(PROTOBUF_ONLY), true), PROTOBUF);
}
#[test]
fn unservable_media_types_match_nothing() {
assert_eq!(negotiate(Some("application/json,text/html"), true), None);
}
#[test]
fn malformed_quality_on_the_only_range_matches_nothing() {
let accept = "application/vnd.google.protobuf;\
proto=io.prometheus.client.MetricFamily;encoding=delimited;q=";
assert_eq!(negotiate(Some(accept), true), None);
}
#[test]
fn unrankable_quality_matches_nothing() {
for weight in [
"nan", "NaN", "+nan", "inf", "infinity", "-inf", "2", "1e3", "-0.5",
] {
let accept = format!("application/openmetrics-text;q={weight}");
assert_eq!(
negotiate(Some(&accept), true),
None,
"q={weight} should invalidate the range"
);
}
}
#[test]
fn unrankable_quality_does_not_mask_a_later_range() {
let accept = format!("application/openmetrics-text;q=nan,{PROTOBUF_PREFERRED}");
assert_eq!(negotiate(Some(&accept), true), PROTOBUF);
}
#[test]
fn out_of_range_quality_does_not_outrank_the_maximum() {
let accept = "application/openmetrics-text;escaping=allow-utf-8;q=5,text/plain;q=1.0";
assert_eq!(negotiate(Some(accept), true), TEXT);
}
#[test]
fn quality_bounds_are_accepted() {
assert_eq!(
negotiate(Some("application/openmetrics-text;q=1.000"), true),
TEXT
);
assert_eq!(
negotiate(Some("application/openmetrics-text;q=0.001"), true),
TEXT
);
}
#[cfg(feature = "foundations-metrics-backend")]
#[test]
fn content_types_come_from_the_encoders() {
assert_eq!(
ScrapeFormat::Protobuf.content_type(),
foundations_metrics::PROTOBUF_CONTENT_TYPE
);
assert_eq!(
ScrapeFormat::Text { utf8_names: true }.content_type(),
foundations_metrics::OPENMETRICS_CONTENT_TYPE
);
}
}
#[cfg(all(test, feature = "logging"))]
mod fallback_logging_tests {
use super::*;
use crate::telemetry::TelemetryContext;
const UNSERVABLE: &str = "application/json,text/html";
#[test]
fn unsatisfiable_accept_is_served_as_text_with_a_warning() {
let ctx = TelemetryContext::test();
let _scope = ctx.scope();
assert_eq!(
negotiate_or_fallback(Some(UNSERVABLE), true),
ScrapeFormat::fallback()
);
let records = ctx.log_records();
let warning = records
.iter()
.find(|record| record.message.contains("no requested metrics format"))
.unwrap_or_else(|| panic!("falling back should warn: {records:?}"));
assert_eq!(warning.level, slog::Level::Warning);
assert!(
warning
.fields
.contains(&("accept".to_owned(), UNSERVABLE.to_owned())),
"the warning should name the header that could not be satisfied: {:?}",
warning.fields
);
}
}