use std::{
collections::BTreeMap,
path::{Path, PathBuf},
time::Duration,
};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::{debug, warn};
use super::{CheckContext, fmt_chain};
use crate::doctor::check::Check;
const CADDY_METRICS_URL: &str = "http://localhost:2019/metrics";
const TIMEOUT: Duration = Duration::from_secs(3);
const WARN_ERROR_PCT: f64 = 5.0;
const FAIL_ERROR_PCT: f64 = 20.0;
const WINDOW: Duration = Duration::from_secs(10 * 60);
const PRUNE_GRACE: Duration = Duration::from_secs(60);
const MIN_HISTORY_AGE: Duration = Duration::from_secs(30);
const IN_RUN_SAMPLE: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Snapshot {
taken_at: Timestamp,
counts: BTreeMap<String, u64>,
}
pub async fn run(ctx: CheckContext) -> Check {
let client = ctx.http_client.clone();
let current_counts = match fetch_counts(&client).await {
FetchResult::Counts(c) => c,
FetchResult::Skip(check) => return check,
};
let current = Snapshot {
taken_at: Timestamp::now(),
counts: current_counts,
};
let state = state_path();
let mut history = state.as_deref().map(load_history).unwrap_or_default();
prune_history(&mut history, current.taken_at);
let (baseline, source) = match pick_baseline(&history, ¤t) {
Some(b) => (b.clone(), BaselineSource::History),
None => {
tokio::time::sleep(IN_RUN_SAMPLE).await;
let second_counts = match fetch_counts(&client).await {
FetchResult::Counts(c) => c,
FetchResult::Skip(check) => return check,
};
let second = Snapshot {
taken_at: Timestamp::now(),
counts: second_counts,
};
let baseline = current.clone();
append_and_save(&state, &mut history, second.clone());
return build_check(&baseline, &second, BaselineSource::InRunSample);
}
};
append_and_save(&state, &mut history, current.clone());
build_check(&baseline, ¤t, source)
}
enum FetchResult {
Counts(BTreeMap<String, u64>),
Skip(Check),
}
async fn fetch_counts(client: &reqwest::Client) -> FetchResult {
let body = match client.get(CADDY_METRICS_URL).timeout(TIMEOUT).send().await {
Ok(resp) if resp.status().is_success() => match resp.text().await {
Ok(t) => t,
Err(err) => {
return FetchResult::Skip(Check::skip(
"http_errors",
"caddy /metrics body read failed",
fmt_chain(&err),
));
}
},
Ok(resp) => {
let status = resp.status().as_u16();
return FetchResult::Skip(Check::skip(
"http_errors",
format!("caddy /metrics returned HTTP {status}"),
format!(
"caddy is reachable but its admin /metrics endpoint isn't usable (HTTP {status}) — error rate cannot be measured"
),
));
}
Err(err) => {
return FetchResult::Skip(Check::skip(
"http_errors",
"caddy admin unreachable",
format!(
"could not reach caddy admin at {CADDY_METRICS_URL}: {}",
fmt_chain(&err)
),
));
}
};
FetchResult::Counts(parse_status_counts(&body))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BaselineSource {
History,
InRunSample,
}
fn pick_baseline<'a>(history: &'a [Snapshot], current: &Snapshot) -> Option<&'a Snapshot> {
history
.iter()
.filter(|s| {
let age = duration_between(s.taken_at, current.taken_at);
age >= MIN_HISTORY_AGE && age <= WINDOW
})
.filter(|s| !counters_reset(&s.counts, ¤t.counts))
.min_by_key(|s| s.taken_at)
}
fn counters_reset(before: &BTreeMap<String, u64>, after: &BTreeMap<String, u64>) -> bool {
before
.iter()
.any(|(code, b)| after.get(code).copied().unwrap_or(0) < *b)
}
fn delta_counts(
before: &BTreeMap<String, u64>,
after: &BTreeMap<String, u64>,
) -> BTreeMap<String, u64> {
let mut out = BTreeMap::new();
for (code, after_n) in after {
let before_n = before.get(code).copied().unwrap_or(0);
let d = after_n.saturating_sub(before_n);
if d > 0 {
out.insert(code.clone(), d);
}
}
out
}
fn build_check(baseline: &Snapshot, current: &Snapshot, source: BaselineSource) -> Check {
let deltas = delta_counts(&baseline.counts, ¤t.counts);
let total: u64 = deltas.values().sum();
let errored: u64 = deltas
.iter()
.filter(|(code, _)| code.starts_with('5'))
.map(|(_, n)| n)
.sum();
let window = duration_between(baseline.taken_at, current.taken_at);
let window_label = humanise_window(window);
let source_label = match source {
BaselineSource::History => "vs history",
BaselineSource::InRunSample => "live sample",
};
if total == 0 {
return Check::pass(
"http_errors",
format!("no requests in last {window_label} ({source_label})"),
)
.with_detail("total_requests", 0u64)
.with_detail("window_seconds", window.as_secs())
.with_detail("baseline_source", source_label);
}
let pct = ((errored as f64 / total as f64) * 100.0).round();
let summary = format!(
"{errored}/{total} server errors ({pct:.0}%) in last {window_label} ({source_label})"
);
let check = if pct >= FAIL_ERROR_PCT {
Check::fail(
"http_errors",
summary.clone(),
format!("≥{FAIL_ERROR_PCT}% error rate"),
)
} else if pct >= WARN_ERROR_PCT {
Check::warning(
"http_errors",
summary.clone(),
format!("≥{WARN_ERROR_PCT}% error rate"),
)
} else {
Check::pass("http_errors", summary)
};
let mut by_code: Map<String, Value> = Map::new();
for (code, n) in &deltas {
by_code.insert(code.clone(), Value::from(*n));
}
check
.with_detail("total_requests", total)
.with_detail("server_error_requests", errored)
.with_detail("server_error_rate_pct", pct)
.with_detail("window_seconds", window.as_secs())
.with_detail("baseline_source", source_label)
.with_detail("by_code", Value::Object(by_code))
}
fn duration_between(earlier: Timestamp, later: Timestamp) -> Duration {
let secs = later.as_second().saturating_sub(earlier.as_second());
Duration::from_secs(secs.max(0) as u64)
}
fn humanise_window(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else {
let m = secs / 60;
let s = secs % 60;
if s == 0 {
format!("{m}m")
} else {
format!("{m}m {s}s")
}
}
}
fn state_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("bestool").join("doctor-http-errors.json"))
}
fn load_history(path: &Path) -> Vec<Snapshot> {
match std::fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<Vec<Snapshot>>(&bytes) {
Ok(v) => v,
Err(err) => {
debug!(%err, ?path, "ignoring unparseable doctor http_errors history");
Vec::new()
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(err) => {
debug!(%err, ?path, "could not read doctor http_errors history");
Vec::new()
}
}
}
fn prune_history(history: &mut Vec<Snapshot>, now: Timestamp) {
let cutoff = WINDOW + PRUNE_GRACE;
history.retain(|s| {
let age = duration_between(s.taken_at, now);
age <= cutoff
});
}
fn append_and_save(path: &Option<PathBuf>, history: &mut Vec<Snapshot>, snapshot: Snapshot) {
history.push(snapshot);
let Some(path) = path else { return };
if let Some(parent) = path.parent()
&& let Err(err) = std::fs::create_dir_all(parent)
{
warn!(%err, ?parent, "could not create doctor http_errors cache dir");
return;
}
let json = match serde_json::to_vec(history) {
Ok(b) => b,
Err(err) => {
warn!(%err, "could not serialise doctor http_errors history");
return;
}
};
let tmp = path.with_extension("json.tmp");
if let Err(err) = std::fs::write(&tmp, &json) {
warn!(%err, ?tmp, "could not write doctor http_errors history");
return;
}
if let Err(err) = std::fs::rename(&tmp, path) {
warn!(%err, ?path, "could not rename doctor http_errors history");
}
}
fn parse_status_counts(body: &str) -> BTreeMap<String, u64> {
use std::collections::HashMap;
let mut per_tuple: HashMap<(String, String, String, String), u64> = HashMap::new();
for line in body.lines() {
if line.starts_with('#') {
continue;
}
let Some(rest) = line.strip_prefix("caddy_http_request_duration_seconds_count") else {
continue;
};
let Some(labels_end) = rest.find('}') else {
continue;
};
let labels = &rest[..labels_end];
let value_part = rest[labels_end + 1..].trim();
let value: u64 = match value_part.split_whitespace().next() {
Some(v) => match v.parse::<f64>() {
Ok(f) => f as u64,
Err(_) => continue,
},
None => continue,
};
let Some(code) = extract_label(labels, "code") else {
continue;
};
let host = extract_label(labels, "host").unwrap_or_default();
let method = extract_label(labels, "method").unwrap_or_default();
let server = extract_label(labels, "server").unwrap_or_default();
let key = (host, method, server, code);
let entry = per_tuple.entry(key).or_insert(0);
*entry = (*entry).max(value);
}
let mut totals: BTreeMap<String, u64> = BTreeMap::new();
for ((_, _, _, code), count) in per_tuple {
*totals.entry(code).or_insert(0) += count;
}
totals
}
fn extract_label(labels: &str, key: &str) -> Option<String> {
let needle = format!("{key}=\"");
let start = labels.find(&needle)? + needle.len();
let rest = &labels[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
# HELP caddy_http_request_duration_seconds Histogram of round-trip request durations.
# TYPE caddy_http_request_duration_seconds histogram
caddy_http_request_duration_seconds_count{code=\"200\",handler=\"encode\",host=\"a\",method=\"GET\",server=\"srv0\"} 3
caddy_http_request_duration_seconds_count{code=\"200\",handler=\"headers\",host=\"a\",method=\"GET\",server=\"srv0\"} 9
caddy_http_request_duration_seconds_count{code=\"200\",handler=\"rate_limit\",host=\"a\",method=\"GET\",server=\"srv0\"} 3
caddy_http_request_duration_seconds_count{code=\"200\",handler=\"reverse_proxy\",host=\"a\",method=\"GET\",server=\"srv0\"} 3
caddy_http_request_duration_seconds_count{code=\"404\",handler=\"headers\",host=\"a\",method=\"GET\",server=\"srv0\"} 12
caddy_http_request_duration_seconds_count{code=\"502\",handler=\"reverse_proxy\",host=\"a\",method=\"POST\",server=\"srv0\"} 3
caddy_http_request_duration_seconds_bucket{code=\"200\",handler=\"encode\",host=\"a\",method=\"GET\",server=\"srv0\",le=\"0.005\"} 3
other_metric{foo=\"bar\"} 7
";
fn snap(secs: i64, counts: &[(&str, u64)]) -> Snapshot {
Snapshot {
taken_at: Timestamp::from_second(secs).unwrap(),
counts: counts.iter().map(|(k, v)| ((*k).to_string(), *v)).collect(),
}
}
#[test]
fn parses_caddy_metric_lines() {
let counts = parse_status_counts(SAMPLE);
assert_eq!(
counts.into_iter().collect::<Vec<_>>(),
vec![
("200".to_string(), 9),
("404".to_string(), 12),
("502".to_string(), 3),
]
);
}
#[test]
fn ignores_unrelated_metrics() {
let counts = parse_status_counts("foo_bar{code=\"500\"} 99");
assert!(counts.is_empty());
}
#[test]
fn label_extract_simple() {
assert_eq!(
extract_label("{code=\"200\",server=\"srv0\"}", "code"),
Some("200".to_string())
);
}
#[test]
fn delta_only_counts_growth() {
let before: BTreeMap<String, u64> =
[("200".to_string(), 10), ("500".to_string(), 2)].into();
let after: BTreeMap<String, u64> = [
("200".to_string(), 15),
("500".to_string(), 4),
("404".to_string(), 1),
]
.into();
let d = delta_counts(&before, &after);
assert_eq!(d.get("200").copied(), Some(5));
assert_eq!(d.get("500").copied(), Some(2));
assert_eq!(d.get("404").copied(), Some(1));
}
#[test]
fn reset_detected_when_any_counter_drops() {
let before: BTreeMap<String, u64> = [("200".to_string(), 10)].into();
let after_dropped: BTreeMap<String, u64> = [("200".to_string(), 5)].into();
assert!(counters_reset(&before, &after_dropped));
let after_grown: BTreeMap<String, u64> = [("200".to_string(), 11)].into();
assert!(!counters_reset(&before, &after_grown));
}
#[test]
fn pick_baseline_prefers_oldest_within_window() {
let now = Timestamp::from_second(10_000).unwrap();
let current = Snapshot {
taken_at: now,
counts: [("200".to_string(), 100)].into(),
};
let history = vec![
snap(10_000 - 700, &[("200", 10)]), snap(10_000 - 540, &[("200", 30)]), snap(10_000 - 300, &[("200", 60)]), snap(10_000 - 10, &[("200", 90)]), ];
let baseline = pick_baseline(&history, ¤t).expect("should pick one");
assert_eq!(baseline.taken_at.as_second(), 10_000 - 540);
}
#[test]
fn pick_baseline_skips_when_only_fresh_snapshots() {
let now = Timestamp::from_second(10_000).unwrap();
let current = Snapshot {
taken_at: now,
counts: [("200".to_string(), 100)].into(),
};
let history = vec![snap(10_000 - 5, &[("200", 95)])];
assert!(pick_baseline(&history, ¤t).is_none());
}
#[test]
fn pick_baseline_skips_resets() {
let now = Timestamp::from_second(10_000).unwrap();
let current = Snapshot {
taken_at: now,
counts: [("200".to_string(), 5)].into(),
};
let history = vec![snap(10_000 - 300, &[("200", 100)])];
assert!(pick_baseline(&history, ¤t).is_none());
}
#[test]
fn prune_drops_snapshots_outside_window_plus_grace() {
let now = Timestamp::from_second(10_000).unwrap();
let mut history = vec![
snap(
10_000 - (WINDOW + PRUNE_GRACE).as_secs() as i64 - 1,
&[("200", 1)],
),
snap(10_000 - WINDOW.as_secs() as i64, &[("200", 2)]),
snap(10_000 - 60, &[("200", 3)]),
];
prune_history(&mut history, now);
assert_eq!(history.len(), 2);
assert_eq!(history[0].counts.get("200").copied(), Some(2));
}
#[test]
fn humanise_window_formats_seconds_and_minutes() {
assert_eq!(humanise_window(Duration::from_secs(10)), "10s");
assert_eq!(humanise_window(Duration::from_secs(60)), "1m");
assert_eq!(humanise_window(Duration::from_secs(540)), "9m");
assert_eq!(humanise_window(Duration::from_secs(545)), "9m 5s");
}
}