use std::collections::BTreeMap;
use std::fmt::Write;
use std::sync::Mutex;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::time::Duration;
const BUCKETS: [f64; 14] = [
0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0,
];
#[derive(Default, Clone)]
struct Histogram {
counts: [u64; BUCKETS.len() + 1],
sum: f64,
}
impl Histogram {
fn observe(&mut self, secs: f64) {
let i = BUCKETS
.iter()
.position(|b| secs <= *b)
.unwrap_or(BUCKETS.len());
self.counts[i] += 1;
self.sum += secs;
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum PageSource {
Scrape,
Batch,
Crawl,
}
#[derive(Default)]
pub(crate) struct Metrics {
requests: Mutex<BTreeMap<(String, u16), u64>>,
latency: Mutex<BTreeMap<String, Histogram>>,
in_flight: AtomicI64,
pub(crate) scrape_ok: AtomicU64,
pub(crate) scrape_err: AtomicU64,
pages: [AtomicU64; 3],
errors: Mutex<BTreeMap<(&'static str, &'static str), u64>>,
jobs_started: Mutex<BTreeMap<&'static str, u64>>,
webhooks: Mutex<BTreeMap<&'static str, u64>>,
}
fn bump<K: Ord>(m: &Mutex<BTreeMap<K, u64>>, k: K, n: u64) {
*m.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(k)
.or_default() += n;
}
impl Metrics {
pub(crate) fn request_started(&self) {
self.in_flight.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn request_finished(&self, route: &str, status: u16, elapsed: Duration) {
self.in_flight.fetch_sub(1, Ordering::Relaxed);
bump(&self.requests, (route.to_string(), status), 1);
self.latency
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(route.to_string())
.or_default()
.observe(elapsed.as_secs_f64());
}
pub(crate) fn page(&self, source: PageSource) {
self.pages[source as usize].fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn error(&self, code: &'static str, source: &'static str) {
bump(&self.errors, (code, source), 1);
}
pub(crate) fn job_started(&self, kind: &'static str) {
bump(&self.jobs_started, kind, 1);
}
pub(crate) fn webhook(&self, outcome: &'static str) {
bump(&self.webhooks, outcome, 1);
}
pub(crate) fn render(&self, jobs: &BTreeMap<(&'static str, &'static str), u64>) -> String {
let mut out = String::new();
header(
&mut out,
"pagesift_http_requests_total",
"counter",
"HTTP requests by route and status.",
);
for ((route, status), n) in snapshot(&self.requests) {
let _ = writeln!(
out,
"pagesift_http_requests_total{{route=\"{route}\",status=\"{status}\"}} {n}"
);
}
header(
&mut out,
"pagesift_http_request_duration_seconds",
"histogram",
"HTTP request latency by route.",
);
let latency = self
.latency
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
for (route, h) in latency {
let mut cum = 0;
for (i, b) in BUCKETS.iter().enumerate() {
cum += h.counts[i];
let _ = writeln!(
out,
"pagesift_http_request_duration_seconds_bucket{{route=\"{route}\",le=\"{b}\"}} {cum}"
);
}
cum += h.counts[BUCKETS.len()];
let _ = writeln!(
out,
"pagesift_http_request_duration_seconds_bucket{{route=\"{route}\",le=\"+Inf\"}} {cum}"
);
let _ = writeln!(
out,
"pagesift_http_request_duration_seconds_sum{{route=\"{route}\"}} {}",
h.sum
);
let _ = writeln!(
out,
"pagesift_http_request_duration_seconds_count{{route=\"{route}\"}} {cum}"
);
}
header(
&mut out,
"pagesift_http_requests_in_flight",
"gauge",
"HTTP requests being served.",
);
let _ = writeln!(
out,
"pagesift_http_requests_in_flight {}",
self.in_flight.load(Ordering::Relaxed)
);
header(
&mut out,
"pagesift_scrapes_total",
"counter",
"Single-page scrapes by outcome.",
);
let _ = writeln!(
out,
"pagesift_scrapes_total{{outcome=\"ok\"}} {}",
self.scrape_ok.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"pagesift_scrapes_total{{outcome=\"error\"}} {}",
self.scrape_err.load(Ordering::Relaxed)
);
header(
&mut out,
"pagesift_pages_scraped_total",
"counter",
"Pages scraped successfully, by endpoint.",
);
for (i, source) in ["scrape", "batch_scrape", "crawl"].iter().enumerate() {
let _ = writeln!(
out,
"pagesift_pages_scraped_total{{source=\"{source}\"}} {}",
self.pages[i].load(Ordering::Relaxed)
);
}
header(
&mut out,
"pagesift_errors_total",
"counter",
"Errors by code; source is http (request) or job (page in a job).",
);
for ((code, source), n) in snapshot(&self.errors) {
let _ = writeln!(
out,
"pagesift_errors_total{{code=\"{code}\",source=\"{source}\"}} {n}"
);
}
header(
&mut out,
"pagesift_jobs_started_total",
"counter",
"Crawl and batch scrape jobs started.",
);
for (kind, n) in snapshot(&self.jobs_started) {
let _ = writeln!(out, "pagesift_jobs_started_total{{kind=\"{kind}\"}} {n}");
}
header(
&mut out,
"pagesift_jobs",
"gauge",
"Jobs kept by the server, by kind and status.",
);
for ((kind, status), n) in jobs {
let _ = writeln!(
out,
"pagesift_jobs{{kind=\"{kind}\",status=\"{status}\"}} {n}"
);
}
header(
&mut out,
"pagesift_webhook_deliveries_total",
"counter",
"Webhook delivery attempts by outcome.",
);
for (outcome, n) in snapshot(&self.webhooks) {
let _ = writeln!(
out,
"pagesift_webhook_deliveries_total{{outcome=\"{outcome}\"}} {n}"
);
}
out
}
}
fn snapshot<K: Ord + Clone>(m: &Mutex<BTreeMap<K, u64>>) -> BTreeMap<K, u64> {
m.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
fn header(out: &mut String, name: &str, kind: &str, help: &str) {
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn histogram_buckets_are_cumulative() {
let m = Metrics::default();
for ms in [0, 3, 3, 200, 60_000] {
m.request_started();
m.request_finished("/v2/scrape", 200, Duration::from_millis(ms));
}
let out = m.render(&BTreeMap::new());
assert!(
out.contains("route=\"/v2/scrape\",le=\"0.001\"} 1\n"),
"{out}"
);
assert!(
out.contains("route=\"/v2/scrape\",le=\"0.005\"} 3\n"),
"{out}"
);
assert!(out.contains("route=\"/v2/scrape\",le=\"30\"} 4\n"), "{out}");
assert!(
out.contains("route=\"/v2/scrape\",le=\"+Inf\"} 5\n"),
"{out}"
);
assert!(
out.contains("pagesift_http_request_duration_seconds_count{route=\"/v2/scrape\"} 5\n")
);
assert!(out.contains("pagesift_http_requests_in_flight 0\n"));
}
}