#![cfg(any(feature = "daemon", feature = "tempo"))]
use crate::ingest::auth_header::AuthHeader;
pub(crate) fn validate_series_name(series: &str) -> Result<(), String> {
let head_ok = matches!(
series.as_bytes().first(),
Some(b'a'..=b'z' | b'A'..=b'Z' | b'_' | b':')
);
if head_ok
&& series
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b':')
{
return Ok(());
}
Err(format!(
"series name must be a bare PromQL metric name \
matching [a-zA-Z_:][a-zA-Z0-9_:]*, got `{series}`"
))
}
pub(crate) fn validate_endpoint(endpoint: &str) -> Result<(), String> {
if endpoint.bytes().any(|b| b < 0x20 || b == 0x7f) {
return Err("endpoint must not contain ASCII control characters".to_string());
}
let uri: crate::http_client::Uri = endpoint
.parse()
.map_err(|e| format!("invalid endpoint URL: {e}"))?;
match uri.scheme_str() {
Some("http" | "https") => {}
Some(other) => {
return Err(format!(
"unsupported scheme `{other}`, only http and https are accepted"
));
}
None => {
return Err("endpoint URL must include a scheme (http:// or https://)".to_string());
}
}
if let Some(authority) = uri.authority()
&& authority.as_str().contains('@')
{
return Err("credentials in the URL are not accepted; use env vars instead".to_string());
}
Ok(())
}
pub(crate) fn validate_label_name(label: &str) -> Result<(), String> {
let head_ok = matches!(
label.as_bytes().first(),
Some(b'a'..=b'z' | b'A'..=b'Z' | b'_')
);
if head_ok
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Ok(());
}
Err(format!(
"label name must be a bare PromQL label matching \
[a-zA-Z_][a-zA-Z0-9_]*, got `{label}`"
))
}
fn by_clause(labels: &[&str]) -> String {
let mut seen: Vec<&str> = Vec::with_capacity(labels.len());
for l in labels {
if !seen.contains(l) {
seen.push(l);
}
}
format!("by%20({})", seen.join("%2C%20"))
}
pub(crate) fn build_topk_query(top_n: usize, series: &str, group_by: &[&str]) -> String {
let by = by_clause(group_by);
format!("topk({top_n}%2C%20sum%20{by}%20({series}))")
}
pub(crate) fn build_counter_query(
calls_series: &str,
group_by: &[&str],
ranked_query: &str,
) -> String {
let by = by_clause(group_by);
let join_label = group_by.first().copied().unwrap_or_default();
format!("sum%20{by}%20({calls_series})%20and%20on({join_label})%20{ranked_query}")
}
pub(crate) async fn fetch_instant_query(
endpoint: &str,
query: &str,
auth_header: Option<&str>,
user_agent: &str,
) -> Result<bytes::Bytes, String> {
let parsed_auth = auth_header
.map(AuthHeader::parse)
.transpose()
.map_err(|msg| format!("invalid auth header: {msg}"))?;
if parsed_auth.is_some() && endpoint.starts_with("http://") {
tracing::warn!(
"Sending auth header over cleartext HTTP, prefer https:// to avoid credential leak"
);
}
let client = crate::http_client::build_client();
let url = format!("{endpoint}/api/v1/query?query={query}");
let uri: crate::http_client::Uri = url.parse().map_err(|e| format!("invalid URL: {e}"))?;
let timeout = std::time::Duration::from_secs(30);
crate::http_client::fetch_get(&client, &uri, user_agent, timeout, parsed_auth.as_ref())
.await
.map_err(|e| {
format!(
"{e} (endpoint: {})",
crate::http_client::redact_endpoint(&uri)
)
})
}
pub(crate) fn instant_query_results(body: &[u8]) -> Result<Vec<serde_json::Value>, String> {
let mut json: serde_json::Value =
serde_json::from_slice(body).map_err(|e| format!("invalid JSON: {e}"))?;
match json
.get_mut("data")
.and_then(|d| d.get_mut("result"))
.map(serde_json::Value::take)
{
Some(serde_json::Value::Array(results)) => Ok(results),
_ => Err("missing data.result array".to_string()),
}
}
pub(crate) fn sample_value(result: &serde_json::Value) -> f64 {
result
.get("value")
.and_then(|v| v.as_array())
.and_then(|arr| arr.get(1))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0)
}
const KEY_SEP: char = '\u{1}';
pub(crate) fn identity_key(metric: &serde_json::Value, labels: &[&str]) -> Option<String> {
let value = |label: &&str| metric.get(*label).and_then(serde_json::Value::as_str);
let mut key = value(labels.first()?)?.to_string();
for label in labels.iter().skip(1) {
key.push(KEY_SEP);
key.push_str(value(label).unwrap_or_default());
}
Some(key)
}
pub(crate) fn counter_by_labels(
body: &[u8],
labels: &[&str],
series: &str,
) -> Result<std::collections::HashMap<String, u64>, String> {
let results = instant_query_results(body)?;
let mut counts: std::collections::HashMap<String, u64> =
std::collections::HashMap::with_capacity(results.len());
for result in &results {
let Some(key) = result.get("metric").and_then(|m| identity_key(m, labels)) else {
continue;
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let count = sample_value(result).max(0.0) as u64;
counts
.entry(key)
.and_modify(|total| *total = total.saturating_add(count))
.or_insert(count);
}
if counts.is_empty() {
tracing::warn!(
series,
labels = ?labels,
"counter query yielded no usable row, either the series does not \
exist or it carries none of the join labels; the column stays at zero"
);
}
Ok(counts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_counter_query_folds_on_the_identity_and_is_bounded_by_the_ranked_set() {
let ranked = build_topk_query(10, "pg_seconds_total", &["queryid", "query"]);
let query = build_counter_query("pg_calls_total", &["queryid"], &ranked);
assert_eq!(
query,
"sum%20by%20(queryid)%20(pg_calls_total)%20and%20on(queryid)%20\
topk(10%2C%20sum%20by%20(queryid%2C%20query)%20(pg_seconds_total))"
);
assert!(!query.contains(' '), "a raw space would break the URL");
}
#[test]
fn the_by_clause_drops_a_repeated_label() {
assert_eq!(
build_topk_query(5, "s", &["queryid", "queryid"]),
"topk(5%2C%20sum%20by%20(queryid)%20(s))"
);
}
#[test]
fn counter_rows_sharing_an_identity_are_summed() {
let body = br#"{"data":{"result":[
{"metric":{"queryid":"42","datname":"app"},"value":[1,"7"]},
{"metric":{"queryid":"42","datname":"reporting"},"value":[1,"3"]},
{"metric":{"datname":"app"},"value":[1,"99"]}]}}"#;
let counts = counter_by_labels(body, &["queryid"], "calls").expect("parse");
assert_eq!(counts.get("42").copied(), Some(10));
assert_eq!(counts.len(), 1, "a row without the identifier is dropped");
}
#[test]
fn a_composite_identity_keeps_the_schemas_apart() {
let body = br#"{"data":{"result":[
{"metric":{"digest":"a1","schema":"shop"},"value":[1,"7"]},
{"metric":{"digest":"a1","schema":"crm"},"value":[1,"3"]}]}}"#;
let counts = counter_by_labels(body, &["digest", "schema"], "calls").expect("parse");
assert_eq!(counts.len(), 2);
let metric = serde_json::json!({"digest": "a1", "schema": "shop"});
let key = identity_key(&metric, &["digest", "schema"]).expect("key");
assert_eq!(counts.get(&key).copied(), Some(7));
}
#[test]
fn an_absent_trailing_label_still_joins() {
let body = br#"{"data":{"result":[{"metric":{"digest":"a1"},"value":[1,"4"]}]}}"#;
let counts = counter_by_labels(body, &["digest", "schema"], "calls").expect("parse");
let metric = serde_json::json!({"digest": "a1"});
let key = identity_key(&metric, &["digest", "schema"]).expect("key");
assert_eq!(counts.get(&key).copied(), Some(4));
assert!(identity_key(&serde_json::json!({"schema": "shop"}), &["digest"]).is_none());
}
#[test]
fn series_name_accepts_the_exporter_defaults() {
for series in [
"pg_stat_statements_seconds_total",
"mysql_perf_schema_events_statements_seconds_total",
"_leading_underscore",
"ns:recorded:rule",
] {
assert!(validate_series_name(series).is_ok(), "{series}");
}
}
#[test]
fn series_name_rejects_anything_that_escapes_the_query_string() {
for series in [
"pg_stat&admin=1",
"x#y",
"has space",
"pg_stat{job=\"db\"}",
"9leading_digit",
"",
] {
assert!(validate_series_name(series).is_err(), "{series}");
}
}
#[test]
fn endpoint_rejects_credentials_control_characters_and_other_schemes() {
for endpoint in [
"http://user:pass@prom:9090",
"ftp://prom:9090",
"prom:9090",
"http://prom:9090\n",
] {
assert!(validate_endpoint(endpoint).is_err(), "{endpoint}");
}
assert!(validate_endpoint("https://prom.example:9090").is_ok());
}
#[test]
fn topk_query_encodes_only_the_commas_and_spaces() {
assert_eq!(
build_topk_query(
10,
"mysql_perf_schema_events_statements_seconds_total",
&["digest", "digest_text", "schema"]
),
"topk(10%2C%20sum%20by%20(digest%2C%20digest_text%2C%20schema)%20\
(mysql_perf_schema_events_statements_seconds_total))"
);
}
#[test]
fn label_name_rejects_what_a_series_name_would_allow() {
assert!(validate_label_name("digest_text").is_ok());
for label in ["ns:recorded", "has space", "x&y", "9lead", ""] {
assert!(validate_label_name(label).is_err(), "{label}");
}
}
#[test]
fn instant_query_reads_results_and_string_samples() {
let body =
br#"{"data":{"result":[{"metric":{"digest_text":"SELECT 1"},"value":[1,"2.5"]}]}}"#;
let results = instant_query_results(body).expect("well-formed response");
assert_eq!(1, results.len());
assert!((sample_value(&results[0]) - 2.5).abs() < f64::EPSILON);
assert!(instant_query_results(b"{}").is_err());
}
}