pub const K07_REQUIRED_METRICS: &[&str] = &[
"apr_num_requests_running",
"apr_num_requests_waiting",
"apr_gpu_cache_usage_perc",
"apr_time_to_first_token_seconds",
"apr_time_per_output_token_seconds",
"apr_e2e_request_latency_seconds",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromContentTypeOutcome {
Ok,
WrongMediaType { got: String },
MissingVersion { got: String },
WrongVersion { got: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromTextFormatOutcome {
Ok { metrics_seen: Vec<String> },
SampleBeforeType { metric: String, line_no: usize },
UnknownType {
metric: String,
got: String,
line_no: usize,
},
SampleValueNotNumeric {
metric: String,
raw: String,
line_no: usize,
},
SampleMissingMetricName { line_no: usize },
DuplicateConflictingType {
metric: String,
first: String,
second: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromRequiredOutcome {
Ok,
Missing { missing: Vec<String> },
}
pub fn classify_content_type(header: &str) -> PromContentTypeOutcome {
let mut parts = header.split(';').map(str::trim);
let media_type = parts.next().unwrap_or("").to_ascii_lowercase();
if media_type != "text/plain" {
return PromContentTypeOutcome::WrongMediaType { got: media_type };
}
let mut version: Option<String> = None;
for kv in parts {
if kv.is_empty() {
continue;
}
let (k, v) = match kv.split_once('=') {
Some((k, v)) => (k.trim().to_ascii_lowercase(), v.trim().to_string()),
None => continue,
};
if k == "version" {
version = Some(v);
}
}
match version {
None => PromContentTypeOutcome::MissingVersion {
got: header.to_string(),
},
Some(v) if v == "0.0.4" => PromContentTypeOutcome::Ok,
Some(v) => PromContentTypeOutcome::WrongVersion { got: v },
}
}
pub fn classify_text_format(body: &str) -> PromTextFormatOutcome {
use std::collections::HashMap;
let valid_types = ["counter", "gauge", "histogram", "summary", "untyped"];
let mut declared_types: HashMap<String, String> = HashMap::new();
let mut metrics_seen: Vec<String> = Vec::new();
for (idx, line) in body.lines().enumerate() {
let line_no = idx + 1;
let trimmed = line.trim_start();
if trimmed.is_empty() {
continue;
}
if let Some(rest) = trimmed.strip_prefix("# TYPE ") {
let mut it = rest.split_ascii_whitespace();
let (Some(name), Some(ty)) = (it.next(), it.next()) else {
continue;
};
if !valid_types.contains(&ty) {
return PromTextFormatOutcome::UnknownType {
metric: name.to_string(),
got: ty.to_string(),
line_no,
};
}
if let Some(prev) = declared_types.get(name) {
if prev != ty {
return PromTextFormatOutcome::DuplicateConflictingType {
metric: name.to_string(),
first: prev.clone(),
second: ty.to_string(),
};
}
} else {
declared_types.insert(name.to_string(), ty.to_string());
metrics_seen.push(name.to_string());
}
continue;
}
if trimmed.starts_with('#') {
continue;
}
let Some((sample_name, value_part)) = split_sample_line(trimmed) else {
return PromTextFormatOutcome::SampleMissingMetricName { line_no };
};
let base = metric_base_name(&sample_name, &declared_types);
if !declared_types.contains_key(&base) {
return PromTextFormatOutcome::SampleBeforeType {
metric: sample_name,
line_no,
};
}
let value_token = value_part.split_ascii_whitespace().next().unwrap_or("");
if !is_prometheus_numeric(value_token) {
return PromTextFormatOutcome::SampleValueNotNumeric {
metric: sample_name,
raw: value_token.to_string(),
line_no,
};
}
}
PromTextFormatOutcome::Ok { metrics_seen }
}
pub fn classify_required_metrics(body: &str, required: &[&str]) -> PromRequiredOutcome {
let mut declared: std::collections::HashSet<String> = std::collections::HashSet::new();
for line in body.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("# TYPE ") {
if let Some(name) = rest.split_ascii_whitespace().next() {
declared.insert(name.to_string());
}
continue;
}
if t.starts_with('#') || t.is_empty() {
continue;
}
if let Some((name, _)) = split_sample_line(t) {
declared.insert(name);
}
}
let missing: Vec<String> = required
.iter()
.filter(|r| {
!declared.contains(**r)
&& !declared.iter().any(|d| {
d == *r
|| d == &format!("{r}_bucket")
|| d == &format!("{r}_sum")
|| d == &format!("{r}_count")
})
})
.map(|s| (*s).to_string())
.collect();
if missing.is_empty() {
PromRequiredOutcome::Ok
} else {
let mut sorted = missing;
sorted.sort();
PromRequiredOutcome::Missing { missing: sorted }
}
}
fn split_sample_line(line: &str) -> Option<(String, String)> {
if let Some(brace) = line.find('{') {
let name = line[..brace].trim();
let close = line[brace..].find('}')? + brace;
let after = line[close + 1..].trim_start();
if name.is_empty() {
return None;
}
return Some((name.to_string(), after.to_string()));
}
let mut split = line.splitn(2, char::is_whitespace);
let name = split.next()?.trim();
let rest = split.next().unwrap_or("").trim_start();
if name.is_empty() || rest.is_empty() {
return None;
}
Some((name.to_string(), rest.to_string()))
}
fn metric_base_name(name: &str, declared: &std::collections::HashMap<String, String>) -> String {
if declared.contains_key(name) {
return name.to_string();
}
for suffix in ["_bucket", "_sum", "_count"] {
if let Some(stripped) = name.strip_suffix(suffix) {
if matches!(
declared.get(stripped).map(String::as_str),
Some("histogram" | "summary")
) {
return stripped.to_string();
}
}
}
name.to_string()
}
fn is_prometheus_numeric(s: &str) -> bool {
matches!(s, "+Inf" | "-Inf" | "Nan" | "NaN") || s.parse::<f64>().is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_type_ok_with_version_and_charset() {
assert_eq!(
classify_content_type("text/plain; version=0.0.4; charset=utf-8"),
PromContentTypeOutcome::Ok
);
}
#[test]
fn content_type_ok_with_only_version() {
assert_eq!(
classify_content_type("text/plain; version=0.0.4"),
PromContentTypeOutcome::Ok
);
}
#[test]
fn content_type_rejects_application_json() {
assert_eq!(
classify_content_type("application/json"),
PromContentTypeOutcome::WrongMediaType {
got: "application/json".to_string()
}
);
}
#[test]
fn content_type_rejects_missing_version() {
let got = classify_content_type("text/plain; charset=utf-8");
assert!(
matches!(got, PromContentTypeOutcome::MissingVersion { .. }),
"{got:?}"
);
}
#[test]
fn content_type_rejects_wrong_version() {
assert_eq!(
classify_content_type("text/plain; version=0.0.5"),
PromContentTypeOutcome::WrongVersion {
got: "0.0.5".to_string()
}
);
}
fn good_body() -> String {
concat!(
"# HELP apr_num_requests_running running requests\n",
"# TYPE apr_num_requests_running gauge\n",
"apr_num_requests_running 3\n",
"# HELP apr_num_requests_waiting queued requests\n",
"# TYPE apr_num_requests_waiting gauge\n",
"apr_num_requests_waiting 0\n",
"# HELP apr_gpu_cache_usage_perc kv cache\n",
"# TYPE apr_gpu_cache_usage_perc gauge\n",
"apr_gpu_cache_usage_perc 0.42\n",
"# TYPE apr_time_to_first_token_seconds histogram\n",
"apr_time_to_first_token_seconds_bucket{le=\"0.5\"} 100\n",
"apr_time_to_first_token_seconds_sum 12.5\n",
"apr_time_to_first_token_seconds_count 200\n",
"# TYPE apr_time_per_output_token_seconds histogram\n",
"apr_time_per_output_token_seconds_bucket{le=\"0.05\"} 80\n",
"apr_time_per_output_token_seconds_sum 1.5\n",
"apr_time_per_output_token_seconds_count 40\n",
"# TYPE apr_e2e_request_latency_seconds histogram\n",
"apr_e2e_request_latency_seconds_bucket{le=\"1.0\"} 50\n",
"apr_e2e_request_latency_seconds_sum 30.0\n",
"apr_e2e_request_latency_seconds_count 60\n",
)
.to_string()
}
#[test]
fn text_format_accepts_well_formed_body() {
let out = classify_text_format(&good_body());
assert!(matches!(out, PromTextFormatOutcome::Ok { .. }), "{out:?}");
}
#[test]
fn text_format_rejects_sample_before_type() {
let body = "apr_num_requests_running 3\n";
assert!(matches!(
classify_text_format(body),
PromTextFormatOutcome::SampleBeforeType { .. }
));
}
#[test]
fn text_format_rejects_unknown_type() {
let body = "# TYPE apr_x widget\napr_x 1\n";
assert!(matches!(
classify_text_format(body),
PromTextFormatOutcome::UnknownType { .. }
));
}
#[test]
fn text_format_rejects_nonnumeric_sample() {
let body = "# TYPE apr_x gauge\napr_x banana\n";
assert!(matches!(
classify_text_format(body),
PromTextFormatOutcome::SampleValueNotNumeric { .. }
));
}
#[test]
fn text_format_accepts_inf_and_nan() {
let body = "# TYPE apr_x gauge\napr_x +Inf\napr_y_g 1\n# TYPE apr_y_g gauge\n";
let body = "# TYPE apr_x gauge\napr_x +Inf\n";
assert!(matches!(
classify_text_format(body),
PromTextFormatOutcome::Ok { .. }
));
let _ = body;
}
#[test]
fn text_format_rejects_duplicate_conflicting_types() {
let body = "# TYPE apr_x counter\n# TYPE apr_x gauge\napr_x 1\n";
assert!(matches!(
classify_text_format(body),
PromTextFormatOutcome::DuplicateConflictingType { .. }
));
}
#[test]
fn required_metrics_pass_on_full_k07_body() {
assert_eq!(
classify_required_metrics(&good_body(), K07_REQUIRED_METRICS),
PromRequiredOutcome::Ok
);
}
#[test]
fn required_metrics_reports_missing_subset() {
let body = "# TYPE apr_num_requests_running gauge\napr_num_requests_running 1\n";
match classify_required_metrics(body, K07_REQUIRED_METRICS) {
PromRequiredOutcome::Missing { missing } => {
assert!(missing.contains(&"apr_num_requests_waiting".to_string()));
assert!(missing.contains(&"apr_e2e_request_latency_seconds".to_string()));
assert!(!missing.contains(&"apr_num_requests_running".to_string()));
}
other => panic!("expected Missing, got {other:?}"),
}
}
#[test]
fn required_metrics_accept_histogram_expansion() {
let body = "# TYPE apr_e2e_request_latency_seconds histogram\n\
apr_e2e_request_latency_seconds_bucket{le=\"1.0\"} 1\n\
apr_e2e_request_latency_seconds_sum 1.0\n\
apr_e2e_request_latency_seconds_count 1\n";
match classify_required_metrics(body, &["apr_e2e_request_latency_seconds"]) {
PromRequiredOutcome::Ok => {}
other => panic!("expected Ok, got {other:?}"),
}
}
}