use std::sync::Arc;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::time::Duration;
use plecto_control::MetricsSnapshot;
const DURATION_BUCKETS: &[f64] = &[
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
struct Histogram {
buckets: Vec<AtomicU64>,
count: AtomicU64,
sum_micros: AtomicU64,
}
impl Histogram {
fn new() -> Self {
Self {
buckets: DURATION_BUCKETS.iter().map(|_| AtomicU64::new(0)).collect(),
count: AtomicU64::new(0),
sum_micros: AtomicU64::new(0),
}
}
fn observe(&self, seconds: f64) {
self.count.fetch_add(1, Ordering::Relaxed);
let micros = (seconds * 1_000_000.0) as u64;
self.sum_micros.fetch_add(micros, Ordering::Relaxed);
for (bound, slot) in DURATION_BUCKETS.iter().zip(self.buckets.iter()) {
if seconds <= *bound {
slot.fetch_add(1, Ordering::Relaxed);
return;
}
}
}
}
pub(crate) struct ServerMetrics {
status_class: [AtomicU64; 5],
in_flight: AtomicI64,
retries: AtomicU64,
circuit_open: AtomicU64,
rate_limited: AtomicU64,
outlier_ejections: AtomicU64,
tunnels_active: AtomicI64,
tunnel_bytes_down: AtomicU64,
tunnel_bytes_up: AtomicU64,
duration: Histogram,
}
pub(crate) struct TunnelActive(Arc<ServerMetrics>);
impl TunnelActive {
pub(crate) fn new(metrics: Arc<ServerMetrics>) -> Self {
metrics.tunnels_active.fetch_add(1, Ordering::Relaxed);
Self(metrics)
}
}
impl Drop for TunnelActive {
fn drop(&mut self) {
self.0.tunnels_active.fetch_sub(1, Ordering::Relaxed);
}
}
impl ServerMetrics {
pub(crate) fn new() -> Self {
Self {
status_class: std::array::from_fn(|_| AtomicU64::new(0)),
in_flight: AtomicI64::new(0),
retries: AtomicU64::new(0),
circuit_open: AtomicU64::new(0),
rate_limited: AtomicU64::new(0),
outlier_ejections: AtomicU64::new(0),
tunnels_active: AtomicI64::new(0),
tunnel_bytes_down: AtomicU64::new(0),
tunnel_bytes_up: AtomicU64::new(0),
duration: Histogram::new(),
}
}
pub(crate) fn add_tunnel_bytes(&self, down: u64, up: u64) {
self.tunnel_bytes_down.fetch_add(down, Ordering::Relaxed);
self.tunnel_bytes_up.fetch_add(up, Ordering::Relaxed);
}
pub(crate) fn inc_in_flight(&self) {
self.in_flight.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn dec_in_flight(&self) {
self.in_flight.fetch_sub(1, Ordering::Relaxed);
}
pub(crate) fn inc_retries(&self) {
self.retries.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn inc_circuit_open(&self) {
self.circuit_open.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn inc_rate_limited(&self) {
self.rate_limited.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn inc_outlier_ejection(&self) {
self.outlier_ejections.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_request(&self, status: u16, elapsed: Duration) {
let idx = (status / 100).clamp(1, 5) as usize - 1;
if let Some(slot) = self.status_class.get(idx) {
slot.fetch_add(1, Ordering::Relaxed);
}
self.duration.observe(elapsed.as_secs_f64());
}
pub(crate) fn render(&self, filter: &MetricsSnapshot, otlp: Option<(u64, usize)>) -> String {
const CLASSES: [&str; 5] = ["1xx", "2xx", "3xx", "4xx", "5xx"];
let mut out: Vec<String> = Vec::new();
out.push(
"# HELP plecto_requests_total Total client requests handled, by response status class."
.to_string(),
);
out.push("# TYPE plecto_requests_total counter".to_string());
for (class, slot) in CLASSES.iter().zip(self.status_class.iter()) {
out.push(format!(
"plecto_requests_total{{status_class=\"{class}\"}} {}",
slot.load(Ordering::Relaxed)
));
}
out.push("# HELP plecto_requests_in_flight Requests currently being served.".to_string());
out.push("# TYPE plecto_requests_in_flight gauge".to_string());
out.push(format!(
"plecto_requests_in_flight {}",
self.in_flight.load(Ordering::Relaxed).max(0)
));
out.push("# HELP plecto_request_duration_seconds Request duration in seconds.".to_string());
out.push("# TYPE plecto_request_duration_seconds histogram".to_string());
let mut cumulative = 0u64;
for (bound, slot) in DURATION_BUCKETS.iter().zip(self.duration.buckets.iter()) {
cumulative += slot.load(Ordering::Relaxed);
out.push(format!(
"plecto_request_duration_seconds_bucket{{le=\"{bound}\"}} {cumulative}"
));
}
let count = self.duration.count.load(Ordering::Relaxed);
out.push(format!(
"plecto_request_duration_seconds_bucket{{le=\"+Inf\"}} {count}"
));
let sum_seconds = self.duration.sum_micros.load(Ordering::Relaxed) as f64 / 1_000_000.0;
out.push(format!("plecto_request_duration_seconds_sum {sum_seconds}"));
out.push(format!("plecto_request_duration_seconds_count {count}"));
out.push(
"# HELP plecto_upstream_retries_total Upstream request retries onto another instance."
.to_string(),
);
out.push("# TYPE plecto_upstream_retries_total counter".to_string());
out.push(format!(
"plecto_upstream_retries_total {}",
self.retries.load(Ordering::Relaxed)
));
out.push(
"# HELP plecto_circuit_open_total Requests shed by an upstream circuit breaker (ADR 000028)."
.to_string(),
);
out.push("# TYPE plecto_circuit_open_total counter".to_string());
out.push(format!(
"plecto_circuit_open_total {}",
self.circuit_open.load(Ordering::Relaxed)
));
out.push(
"# HELP plecto_rate_limited_total Requests rejected by a native route rate limit (ADR 000033)."
.to_string(),
);
out.push("# TYPE plecto_rate_limited_total counter".to_string());
out.push(format!(
"plecto_rate_limited_total {}",
self.rate_limited.load(Ordering::Relaxed)
));
out.push(
"# HELP plecto_outlier_ejections_total Instances ejected from rotation by outlier detection (ADR 000032)."
.to_string(),
);
out.push("# TYPE plecto_outlier_ejections_total counter".to_string());
out.push(format!(
"plecto_outlier_ejections_total {}",
self.outlier_ejections.load(Ordering::Relaxed)
));
out.push(
"# HELP plecto_tunnels_active Upgrade tunnels currently open (ADR 000048/000059); each holds a breaker permit and an LB pick."
.to_string(),
);
out.push("# TYPE plecto_tunnels_active gauge".to_string());
out.push(format!(
"plecto_tunnels_active {}",
self.tunnels_active.load(Ordering::Relaxed).max(0)
));
out.push(
"# HELP plecto_tunnel_bytes_down_total Bytes relayed downstream (upstream to client) by upgrade tunnels, recorded at tunnel close."
.to_string(),
);
out.push("# TYPE plecto_tunnel_bytes_down_total counter".to_string());
out.push(format!(
"plecto_tunnel_bytes_down_total {}",
self.tunnel_bytes_down.load(Ordering::Relaxed)
));
out.push(
"# HELP plecto_tunnel_bytes_up_total Bytes relayed upstream (client to upstream) by upgrade tunnels, recorded at tunnel close."
.to_string(),
);
out.push("# TYPE plecto_tunnel_bytes_up_total counter".to_string());
out.push(format!(
"plecto_tunnel_bytes_up_total {}",
self.tunnel_bytes_up.load(Ordering::Relaxed)
));
out.push(
"# HELP plecto_filter_executions_total Filter hook executions (host-aggregated)."
.to_string(),
);
out.push("# TYPE plecto_filter_executions_total counter".to_string());
out.push(format!("plecto_filter_executions_total {}", filter.total));
out.push(
"# HELP plecto_filter_errors_total Filter executions that faulted (trap/deadline/instantiate/unavailable)."
.to_string(),
);
out.push("# TYPE plecto_filter_errors_total counter".to_string());
out.push(format!("plecto_filter_errors_total {}", filter.errors));
out.push(
"# HELP plecto_filter_short_circuits_total Filter executions that short-circuited the chain."
.to_string(),
);
out.push("# TYPE plecto_filter_short_circuits_total counter".to_string());
out.push(format!(
"plecto_filter_short_circuits_total {}",
filter.short_circuits
));
out.push(
"# HELP plecto_filter_duration_seconds_total Total filter execution time in seconds."
.to_string(),
);
out.push("# TYPE plecto_filter_duration_seconds_total counter".to_string());
out.push(format!(
"plecto_filter_duration_seconds_total {}",
filter.total_duration.as_secs_f64()
));
if let Some((dropped, queued)) = otlp {
out.push(
"# HELP plecto_otlp_dropped_spans_total Spans lost to a full queue or failed exports."
.to_string(),
);
out.push("# TYPE plecto_otlp_dropped_spans_total counter".to_string());
out.push(format!("plecto_otlp_dropped_spans_total {dropped}"));
out.push(
"# HELP plecto_otlp_queue_spans Spans currently queued for export.".to_string(),
);
out.push("# TYPE plecto_otlp_queue_spans gauge".to_string());
out.push(format!("plecto_otlp_queue_spans {queued}"));
}
let mut text = out.join("\n");
text.push('\n');
text
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(total: u64, errors: u64, short_circuits: u64) -> MetricsSnapshot {
MetricsSnapshot {
total,
errors,
short_circuits,
total_duration: Duration::from_millis(0),
}
}
#[test]
fn records_status_classes_and_renders_prometheus_exposition() {
let m = ServerMetrics::new();
m.record_request(200, Duration::from_millis(3));
m.record_request(204, Duration::from_millis(7));
m.record_request(404, Duration::from_millis(1));
m.record_request(503, Duration::from_millis(50));
let text = m.render(&snap(0, 0, 0), None);
assert!(text.contains("plecto_requests_total{status_class=\"2xx\"} 2"));
assert!(text.contains("plecto_requests_total{status_class=\"4xx\"} 1"));
assert!(text.contains("plecto_requests_total{status_class=\"5xx\"} 1"));
assert!(text.contains("plecto_requests_total{status_class=\"3xx\"} 0"));
assert!(text.contains("plecto_request_duration_seconds_count 4"));
assert!(text.contains("# TYPE plecto_request_duration_seconds histogram"));
}
#[test]
fn out_of_range_status_does_not_panic() {
let m = ServerMetrics::new();
for status in [0u16, 99, 600, 999, u16::MAX] {
m.record_request(status, Duration::from_millis(1));
}
assert!(
m.render(&snap(0, 0, 0), None)
.contains("plecto_request_duration_seconds_count 5")
);
}
#[test]
fn histogram_buckets_are_cumulative_and_end_at_total_count() {
let m = ServerMetrics::new();
for ms in [2u64, 8, 30, 300] {
m.record_request(200, Duration::from_millis(ms));
}
let text = m.render(&snap(0, 0, 0), None);
let counts: Vec<u64> = text
.lines()
.filter(|l| l.starts_with("plecto_request_duration_seconds_bucket"))
.filter_map(|l| l.rsplit(' ').next())
.filter_map(|n| n.parse::<u64>().ok())
.collect();
assert!(
counts.windows(2).all(|w| w[0] <= w[1]),
"buckets must be cumulative / non-decreasing: {counts:?}"
);
assert_eq!(
counts.last().copied(),
Some(4),
"the +Inf bucket equals the total observation count"
);
}
#[test]
fn in_flight_gauge_tracks_the_balance() {
let m = ServerMetrics::new();
m.inc_in_flight();
m.inc_in_flight();
m.dec_in_flight();
assert!(
m.render(&snap(0, 0, 0), None)
.contains("plecto_requests_in_flight 1")
);
}
#[test]
fn tunnel_gauge_follows_the_guard_and_byte_counters_accumulate() {
let m = Arc::new(ServerMetrics::new());
let g1 = TunnelActive::new(m.clone());
let g2 = TunnelActive::new(m.clone());
assert!(
m.render(&snap(0, 0, 0), None)
.contains("plecto_tunnels_active 2")
);
drop(g1);
m.add_tunnel_bytes(19, 7);
m.add_tunnel_bytes(1, 2);
let text = m.render(&snap(0, 0, 0), None);
assert!(text.contains("plecto_tunnels_active 1"));
assert!(text.contains("plecto_tunnel_bytes_down_total 20"));
assert!(text.contains("plecto_tunnel_bytes_up_total 9"));
drop(g2);
assert!(
m.render(&snap(0, 0, 0), None)
.contains("plecto_tunnels_active 0")
);
}
#[test]
fn folds_in_host_filter_metrics() {
let m = ServerMetrics::new();
let text = m.render(&snap(5, 2, 1), None);
assert!(text.contains("plecto_filter_executions_total 5"));
assert!(text.contains("plecto_filter_errors_total 2"));
assert!(text.contains("plecto_filter_short_circuits_total 1"));
}
#[test]
fn otlp_queue_telemetry_renders_only_when_export_is_configured() {
let m = ServerMetrics::new();
let off = m.render(&snap(0, 0, 0), None);
assert!(
!off.contains("plecto_otlp_"),
"no OTLP lines without an exporter"
);
let on = m.render(&snap(0, 0, 0), Some((7, 3)));
assert!(on.contains("plecto_otlp_dropped_spans_total 7"));
assert!(on.contains("plecto_otlp_queue_spans 3"));
}
}