use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::time::Duration;
use arc_swap::ArcSwap;
use plecto_control::{INLINE_DURATION_BUCKETS, MetricsSnapshot, UpstreamGroup};
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,
];
const UPSTREAM_STATES: [&str; 3] = ["healthy", "unhealthy", "ejected"];
fn instance_state(healthy: bool, ejected: bool) -> usize {
match (ejected, healthy) {
(true, _) => 2,
(false, true) => 0,
(false, false) => 1,
}
}
fn escape_label_value(value: &str) -> String {
if !value.contains(['\\', '"', '\n']) {
return value.to_string();
}
let mut out = String::with_capacity(value.len() + 8);
for c in value.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
_ => out.push(c),
}
}
out
}
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;
}
}
}
}
struct RouteCounters {
status_class: [AtomicU64; 5],
rate_limited: AtomicU64,
}
impl RouteCounters {
fn new() -> Self {
Self {
status_class: std::array::from_fn(|_| AtomicU64::new(0)),
rate_limited: AtomicU64::new(0),
}
}
}
pub(crate) struct ServerMetrics {
routes: ArcSwap<BTreeMap<Arc<str>, Arc<RouteCounters>>>,
in_flight: AtomicI64,
retries: AtomicU64,
circuit_open: AtomicU64,
outlier_ejections: AtomicU64,
tunnels_active: AtomicI64,
tunnel_bytes_down: AtomicU64,
tunnel_bytes_up: AtomicU64,
inspection_skipped: [AtomicU64; InspectionSkip::COUNT],
duration: Histogram,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InspectionSkip {
StreamingContentType,
ContentEncoding,
PartialContent,
OverCap,
}
impl InspectionSkip {
const COUNT: usize = 4;
fn index(self) -> usize {
match self {
InspectionSkip::StreamingContentType => 0,
InspectionSkip::ContentEncoding => 1,
InspectionSkip::PartialContent => 2,
InspectionSkip::OverCap => 3,
}
}
const ALL: [InspectionSkip; Self::COUNT] = [
InspectionSkip::StreamingContentType,
InspectionSkip::ContentEncoding,
InspectionSkip::PartialContent,
InspectionSkip::OverCap,
];
pub(crate) fn as_str(self) -> &'static str {
match self {
InspectionSkip::StreamingContentType => "streaming-content-type",
InspectionSkip::ContentEncoding => "content-encoding",
InspectionSkip::PartialContent => "partial-content",
InspectionSkip::OverCap => "over-cap",
}
}
}
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 {
let mut routes = BTreeMap::new();
routes.insert(
Arc::from(plecto_control::UNMATCHED_ROUTE),
Arc::new(RouteCounters::new()),
);
Self {
routes: ArcSwap::from_pointee(routes),
in_flight: AtomicI64::new(0),
retries: AtomicU64::new(0),
circuit_open: 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),
inspection_skipped: std::array::from_fn(|_| AtomicU64::new(0)),
duration: Histogram::new(),
}
}
fn insert_route_lazy(&self, route: &str) -> Arc<RouteCounters> {
self.routes.rcu(|current| {
if current.contains_key(route) {
return Arc::clone(current);
}
let mut map = (**current).clone();
map.insert(Arc::from(route), Arc::new(RouteCounters::new()));
Arc::new(map)
});
self.routes
.load()
.get(route)
.cloned()
.unwrap_or_else(|| Arc::new(RouteCounters::new()))
}
fn counters(&self, route: &str) -> Arc<RouteCounters> {
let routes = self.routes.load();
if let Some(counters) = routes.get(route) {
counters.clone()
} else {
drop(routes);
self.insert_route_lazy(route)
}
}
pub(crate) fn inc_inspection_skipped(&self, reason: InspectionSkip) {
if let Some(slot) = self.inspection_skipped.get(reason.index()) {
slot.fetch_add(1, Ordering::Relaxed);
}
}
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, route: &str) {
self.counters(route)
.rate_limited
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn register_routes(&self, names: &[Arc<str>]) {
let current = self.routes.load();
let any_missing = names
.iter()
.any(|name| !current.contains_key(name.as_ref()));
if !any_missing {
return;
}
drop(current);
self.routes.rcu(|current| {
let mut missing = Vec::new();
for name in names {
if !current.contains_key(name.as_ref()) {
missing.push(name.clone());
}
}
if missing.is_empty() {
return Arc::clone(current);
}
let mut map = (**current).clone();
for name in missing {
map.entry(name)
.or_insert_with(|| Arc::new(RouteCounters::new()));
}
Arc::new(map)
});
}
pub(crate) fn inc_outlier_ejection(&self) {
self.outlier_ejections.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_request(&self, route: &str, status: u16, elapsed: Duration) {
let idx = (status / 100).clamp(1, 5) as usize - 1;
let counters = self.counters(route);
if let Some(slot) = counters.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)>,
pool: Option<plecto_control::PoolResidency>,
upstreams: &[Arc<UpstreamGroup>],
) -> String {
const CLASSES: [&str; 5] = ["1xx", "2xx", "3xx", "4xx", "5xx"];
let mut out: Vec<String> = Vec::new();
let routes = self.routes.load();
out.push(
"# HELP plecto_requests_total Total client requests handled, by route and response status class."
.to_string(),
);
out.push("# TYPE plecto_requests_total counter".to_string());
for (route, counters) in routes.iter() {
let escaped_route = escape_label_value(route);
for (class, slot) in CLASSES.iter().zip(counters.status_class.iter()) {
out.push(format!(
"plecto_requests_total{{route=\"{escaped_route}\",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, by route (ADR 000033 / 000112)."
.to_string(),
);
out.push("# TYPE plecto_rate_limited_total counter".to_string());
for (route, counters) in routes.iter() {
if route.as_ref() == plecto_control::UNMATCHED_ROUTE {
continue;
}
let escaped_route = escape_label_value(route);
out.push(format!(
"plecto_rate_limited_total{{route=\"{escaped_route}\"}} {}",
counters.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_response_body_inspection_skipped_total Responses a route's on-response-body filters did not inspect, by reason (ADR 000098)."
.to_string(),
);
out.push("# TYPE plecto_response_body_inspection_skipped_total counter".to_string());
for reason in InspectionSkip::ALL {
let count = self
.inspection_skipped
.get(reason.index())
.map_or(0, |slot| slot.load(Ordering::Relaxed));
out.push(format!(
"plecto_response_body_inspection_skipped_total{{reason=\"{}\"}} {count}",
reason.as_str()
));
}
out.push(
"# HELP plecto_upstream_instances Upstream instances by state (ADR 000017 / 000032)."
.to_string(),
);
out.push("# TYPE plecto_upstream_instances gauge".to_string());
for group in upstreams {
let mut counts = [0u64; UPSTREAM_STATES.len()];
for instance in &group.endpoints().instances {
let state =
instance_state(instance.is_healthy(), instance.is_outlier_ejected_now());
if let Some(slot) = counts.get_mut(state) {
*slot += 1;
}
}
let name = escape_label_value(&group.name);
for (state, count) in UPSTREAM_STATES.iter().zip(counts) {
out.push(format!(
"plecto_upstream_instances{{upstream=\"{name}\",state=\"{state}\"}} {count}"
));
}
}
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()
));
out.push(
"# HELP plecto_filter_inline_fallbacks_total Inline-eligible filter hook calls that fell back to the blocking pool, by reason."
.to_string(),
);
out.push("# TYPE plecto_filter_inline_fallbacks_total counter".to_string());
out.push(format!(
"plecto_filter_inline_fallbacks_total{{reason=\"pool_exhausted\"}} {}",
filter.inline.fallbacks_pool_exhausted
));
out.push(format!(
"plecto_filter_inline_fallbacks_total{{reason=\"pool_filling\"}} {}",
filter.inline.fallbacks_pool_filling
));
out.push(format!(
"plecto_filter_inline_fallbacks_total{{reason=\"breaker_open\"}} {}",
filter.inline.fallbacks_breaker_open
));
out.push(
"# HELP plecto_filter_inline_duration_seconds Inline filter execution duration in seconds."
.to_string(),
);
out.push("# TYPE plecto_filter_inline_duration_seconds histogram".to_string());
let mut cumulative = 0u64;
for (bound, count) in INLINE_DURATION_BUCKETS
.iter()
.zip(filter.inline.duration_buckets.iter())
{
cumulative += count;
out.push(format!(
"plecto_filter_inline_duration_seconds_bucket{{le=\"{bound}\"}} {cumulative}"
));
}
out.push(format!(
"plecto_filter_inline_duration_seconds_bucket{{le=\"+Inf\"}} {}",
filter.inline.duration_count
));
out.push(format!(
"plecto_filter_inline_duration_seconds_sum {}",
filter.inline.duration_sum.as_secs_f64()
));
out.push(format!(
"plecto_filter_inline_duration_seconds_count {}",
filter.inline.duration_count
));
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}"));
}
if let Some(pool) = pool {
out.push(
"# HELP plecto_pool_component_instances Live component instances in the trusted engine's pooling allocator."
.to_string(),
);
out.push("# TYPE plecto_pool_component_instances gauge".to_string());
out.push(format!(
"plecto_pool_component_instances {}",
pool.component_instances
));
out.push(
"# HELP plecto_pool_memories Live pooled linear memories in the trusted engine."
.to_string(),
);
out.push("# TYPE plecto_pool_memories gauge".to_string());
out.push(format!("plecto_pool_memories {}", pool.memories));
out.push(
"# HELP plecto_pool_unused_memory_resident_bytes Bytes kept resident for unused-but-warm pool slots."
.to_string(),
);
out.push("# TYPE plecto_pool_unused_memory_resident_bytes gauge".to_string());
out.push(format!(
"plecto_pool_unused_memory_resident_bytes {}",
pool.unused_memory_bytes_resident
));
}
let mut text = out.join("\n");
text.push('\n');
text
}
}
#[cfg(test)]
mod tests {
use super::*;
use plecto_control::InlineMetricsSnapshot;
fn snap(total: u64, errors: u64, short_circuits: u64) -> MetricsSnapshot {
MetricsSnapshot {
total,
errors,
short_circuits,
total_duration: Duration::from_millis(0),
inline: InlineMetricsSnapshot::default(),
}
}
#[test]
fn records_status_classes_and_renders_prometheus_exposition() {
let m = ServerMetrics::new();
m.record_request(
plecto_control::UNMATCHED_ROUTE,
200,
Duration::from_millis(3),
);
m.record_request(
plecto_control::UNMATCHED_ROUTE,
204,
Duration::from_millis(7),
);
m.record_request(
plecto_control::UNMATCHED_ROUTE,
404,
Duration::from_millis(1),
);
m.record_request(
plecto_control::UNMATCHED_ROUTE,
503,
Duration::from_millis(50),
);
let text = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(text.contains("plecto_requests_total{route=\"unmatched\",status_class=\"2xx\"} 2"));
assert!(text.contains("plecto_requests_total{route=\"unmatched\",status_class=\"4xx\"} 1"));
assert!(text.contains("plecto_requests_total{route=\"unmatched\",status_class=\"5xx\"} 1"));
assert!(text.contains("plecto_requests_total{route=\"unmatched\",status_class=\"3xx\"} 0"));
assert!(text.contains("plecto_request_duration_seconds_count 4"));
assert!(text.contains("# TYPE plecto_request_duration_seconds histogram"));
}
#[test]
fn every_inspection_skip_reason_is_a_series_that_always_exists() {
let m = ServerMetrics::new();
let text = m.render(&snap(0, 0, 0), None, None, &[]);
for reason in InspectionSkip::ALL {
assert!(
text.contains(&format!(
"plecto_response_body_inspection_skipped_total{{reason=\"{}\"}} 0",
reason.as_str()
)),
"{} must be exposed before it ever fires",
reason.as_str()
);
}
m.inc_inspection_skipped(InspectionSkip::OverCap);
m.inc_inspection_skipped(InspectionSkip::OverCap);
m.inc_inspection_skipped(InspectionSkip::ContentEncoding);
let text = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(
text.contains("plecto_response_body_inspection_skipped_total{reason=\"over-cap\"} 2")
);
assert!(text.contains(
"plecto_response_body_inspection_skipped_total{reason=\"content-encoding\"} 1"
));
assert!(text.contains(
"plecto_response_body_inspection_skipped_total{reason=\"partial-content\"} 0"
));
}
#[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(
plecto_control::UNMATCHED_ROUTE,
status,
Duration::from_millis(1),
);
}
assert!(
m.render(&snap(0, 0, 0), None, 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(
plecto_control::UNMATCHED_ROUTE,
200,
Duration::from_millis(ms),
);
}
let text = m.render(&snap(0, 0, 0), None, 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, 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, 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, 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, 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, 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, None, &[]);
assert!(
!off.contains("plecto_otlp_"),
"no OTLP lines without an exporter"
);
let on = m.render(&snap(0, 0, 0), Some((7, 3)), None, &[]);
assert!(on.contains("plecto_otlp_dropped_spans_total 7"));
assert!(on.contains("plecto_otlp_queue_spans 3"));
}
#[test]
fn pool_residency_gauges_render_only_when_the_host_reports_them() {
let m = ServerMetrics::new();
let off = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(
!off.contains("plecto_pool_"),
"no pool lines without a pooling engine"
);
let pool = plecto_control::PoolResidency {
component_instances: 3,
memories: 2,
unused_memory_bytes_resident: 4096,
};
let on = m.render(&snap(0, 0, 0), None, Some(pool), &[]);
assert!(
on.contains("plecto_pool_component_instances 3"),
"live pooled component instances:\n{on}"
);
assert!(on.contains("plecto_pool_memories 2"), "{on}");
assert!(
on.contains("plecto_pool_unused_memory_resident_bytes 4096"),
"{on}"
);
}
fn groups(toml: &str, names: &[&str]) -> Vec<Arc<UpstreamGroup>> {
let manifest = plecto_control::Manifest::from_toml(toml).expect("the manifest parses");
let registry = plecto_control::UpstreamRegistry::new();
registry
.reconcile(&manifest.upstreams, std::path::Path::new("."))
.expect("the upstreams reconcile");
names
.iter()
.map(|n| registry.group(n).expect("a reconciled group"))
.collect()
}
fn state_count(text: &str, upstream: &str, state: &str) -> Option<u64> {
let prefix =
format!("plecto_upstream_instances{{upstream=\"{upstream}\",state=\"{state}\"}} ");
text.lines()
.find_map(|l| l.strip_prefix(prefix.as_str()))
.and_then(|n| n.trim().parse().ok())
}
const TWO_INSTANCE_UPSTREAM: &str = r#"
[[upstream]]
name = "u"
addresses = ["10.0.0.1:80", "10.0.0.2:80"]
[upstream.health]
path = "/healthz"
[upstream.outlier_detection]
consecutive_gateway_failures = 1
base_ejection_time_ms = 60000
max_ejection_percent = 100
"#;
#[test]
fn upstream_instances_count_a_pessimistic_pool_as_unhealthy() {
let g = groups(TWO_INSTANCE_UPSTREAM, &["u"]);
let text = ServerMetrics::new().render(&snap(0, 0, 0), None, None, &g);
assert!(
text.contains("# TYPE plecto_upstream_instances gauge"),
"the gauge is declared:\n{text}"
);
assert_eq!(state_count(&text, "u", "unhealthy"), Some(2), "{text}");
assert_eq!(state_count(&text, "u", "healthy"), Some(0), "{text}");
assert_eq!(state_count(&text, "u", "ejected"), Some(0), "{text}");
}
#[test]
fn upstream_instances_count_a_probed_pool_as_healthy() {
let g = groups(TWO_INSTANCE_UPSTREAM, &["u"]);
for inst in &g[0].endpoints().instances {
inst.record_probe_success();
}
let text = ServerMetrics::new().render(&snap(0, 0, 0), None, None, &g);
assert_eq!(state_count(&text, "u", "healthy"), Some(2), "{text}");
assert_eq!(state_count(&text, "u", "unhealthy"), Some(0), "{text}");
assert_eq!(state_count(&text, "u", "ejected"), Some(0), "{text}");
}
#[test]
fn upstream_instances_fold_an_ejected_but_probe_healthy_instance_as_ejected() {
let g = groups(TWO_INSTANCE_UPSTREAM, &["u"]);
let instances = g[0].endpoints().instances.clone();
for inst in &instances {
inst.record_probe_success();
}
assert!(
g[0].record_outcome(&instances[0], true),
"one gateway failure crosses the threshold and ejects"
);
assert!(
instances[0].is_healthy(),
"the probe bit is untouched — this is the independent-axes case the fold exists for"
);
let text = ServerMetrics::new().render(&snap(0, 0, 0), None, None, &g);
assert_eq!(state_count(&text, "u", "ejected"), Some(1), "{text}");
assert_eq!(state_count(&text, "u", "healthy"), Some(1), "{text}");
assert_eq!(state_count(&text, "u", "unhealthy"), Some(0), "{text}");
}
#[test]
fn upstream_instances_render_every_state_of_every_upstream_exactly_once() {
let toml = r#"
[[upstream]]
name = "a"
addresses = ["10.0.0.1:80", "10.0.0.2:80", "10.0.0.3:80"]
[upstream.health]
path = "/healthz"
[[upstream]]
name = "b"
addresses = ["10.0.1.1:80"]
[upstream.health]
path = "/healthz"
"#;
let g = groups(toml, &["a", "b"]);
g[0].endpoints().instances[0].record_probe_success();
let text = ServerMetrics::new().render(&snap(0, 0, 0), None, None, &g);
let series: Vec<&str> = text
.lines()
.filter(|l| l.starts_with("plecto_upstream_instances{"))
.collect();
assert_eq!(
series.len(),
6,
"two upstreams Ă— three states, no more:\n{text}"
);
assert_eq!(state_count(&text, "a", "healthy"), Some(1), "{text}");
assert_eq!(state_count(&text, "a", "unhealthy"), Some(2), "{text}");
assert_eq!(state_count(&text, "a", "ejected"), Some(0), "{text}");
assert_eq!(state_count(&text, "b", "unhealthy"), Some(1), "{text}");
for upstream in ["a", "b"] {
let sum: u64 = ["healthy", "unhealthy", "ejected"]
.iter()
.filter_map(|s| state_count(&text, upstream, s))
.sum();
let instances = g
.iter()
.find(|group| group.name == upstream)
.map(|group| group.endpoints().instances.len() as u64);
assert_eq!(
Some(sum),
instances,
"upstream {upstream}'s series sum to its instance count:\n{text}"
);
}
}
#[test]
fn upstream_label_values_are_escaped() {
let toml = r#"
[[upstream]]
name = "we\"ird\\name"
addresses = ["10.0.0.1:80"]
[upstream.health]
path = "/healthz"
"#;
let g = groups(toml, &["we\"ird\\name"]);
let text = ServerMetrics::new().render(&snap(0, 0, 0), None, None, &g);
assert!(
text.contains(
"plecto_upstream_instances{upstream=\"we\\\"ird\\\\name\",state=\"unhealthy\"} 1"
),
"the quote and backslash are escaped:\n{text}"
);
}
#[test]
fn per_route_status_classes_counted_and_rendered() {
let m = ServerMetrics::new();
m.register_routes(&[Arc::from("api"), Arc::from("web")]);
m.record_request("api", 200, Duration::from_millis(5));
m.record_request("api", 200, Duration::from_millis(5));
m.record_request("api", 500, Duration::from_millis(10));
m.record_request("web", 404, Duration::from_millis(2));
let text = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(
text.contains("plecto_requests_total{route=\"api\",status_class=\"2xx\"} 2"),
"api 2xx counted and labelled in route, status_class order:\n{text}"
);
assert!(
text.contains("plecto_requests_total{route=\"api\",status_class=\"5xx\"} 1"),
"api 5xx counted and labelled in route, status_class order:\n{text}"
);
assert!(
text.contains("plecto_requests_total{route=\"web\",status_class=\"4xx\"} 1"),
"web 4xx counted and labelled in route, status_class order:\n{text}"
);
}
#[test]
fn registered_routes_appear_at_zero() {
let m = ServerMetrics::new();
m.register_routes(&[Arc::from("api")]);
let text = m.render(&snap(0, 0, 0), None, None, &[]);
for class in ["1xx", "2xx", "3xx", "4xx", "5xx"] {
assert!(
text.contains(&format!(
"plecto_requests_total{{route=\"api\",status_class=\"{class}\"}} 0"
)),
"registered route api must have {class} present at 0:\n{text}"
);
assert!(
text.contains(&format!(
"plecto_requests_total{{route=\"unmatched\",status_class=\"{class}\"}} 0"
)),
"unmatched route must have {class} present at 0:\n{text}"
);
}
assert!(
text.contains("plecto_rate_limited_total{route=\"api\"} 0"),
"rate limited total for registered route api must be present at 0:\n{text}"
);
}
#[test]
fn route_dropped_from_later_registration_keeps_frozen_series() {
let m = ServerMetrics::new();
m.register_routes(&[Arc::from("r1"), Arc::from("r2")]);
m.record_request("r1", 200, Duration::from_millis(5));
let text1 = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(text1.contains("plecto_requests_total{route=\"r1\",status_class=\"2xx\"} 1"));
m.register_routes(&[Arc::from("r2"), Arc::from("r3")]);
let text2 = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(
text2.contains("plecto_requests_total{route=\"r1\",status_class=\"2xx\"} 1"),
"dropped route r1 series must stay frozen at its earlier value:\n{text2}"
);
assert!(
text2.contains("plecto_requests_total{route=\"r3\",status_class=\"2xx\"} 0"),
"newly added route r3 series must appear at 0:\n{text2}"
);
}
#[test]
fn recording_on_not_yet_registered_route_lazy_inserts_and_counts() {
let m = ServerMetrics::new();
m.record_request("unregistered", 200, Duration::from_millis(5));
let text = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(
text.contains("plecto_requests_total{route=\"unregistered\",status_class=\"2xx\"} 1"),
"lazy-inserted route must be counted and exposed:\n{text}"
);
}
#[test]
fn rate_limited_labelled_per_route_and_absent_for_unmatched() {
let m = ServerMetrics::new();
m.register_routes(&[Arc::from("api")]);
m.inc_rate_limited("api");
m.inc_rate_limited(plecto_control::UNMATCHED_ROUTE);
let text = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(
text.contains("plecto_rate_limited_total{route=\"api\"} 1"),
"rate_limited_total must carry route label:\n{text}"
);
assert!(
!text.contains("plecto_rate_limited_total{route=\"unmatched\"}"),
"rate_limited_total must never be emitted for unmatched:\n{text}"
);
}
#[test]
fn route_label_values_with_quotes_are_escaped() {
let m = ServerMetrics::new();
m.register_routes(&[Arc::from("we\"ird\\route")]);
m.record_request("we\"ird\\route", 200, Duration::from_millis(5));
let text = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(
text.contains(
"plecto_requests_total{route=\"we\\\"ird\\\\route\",status_class=\"2xx\"} 1"
),
"quotes and backslashes in route names must be escaped:\n{text}"
);
}
#[test]
fn request_duration_histogram_stays_unlabelled() {
let m = ServerMetrics::new();
m.register_routes(&[Arc::from("api")]);
m.record_request("api", 200, Duration::from_millis(5));
let text = m.render(&snap(0, 0, 0), None, None, &[]);
assert!(
text.contains("plecto_request_duration_seconds_count 1"),
"duration histogram count is exposed unlabelled:\n{text}"
);
assert!(
!text.contains("plecto_request_duration_seconds_count{"),
"duration histogram count must not have label brackets:\n{text}"
);
for line in text
.lines()
.filter(|l| l.starts_with("plecto_request_duration_seconds"))
{
assert!(
!line.contains("route="),
"duration histogram series must not carry route labels (ADR 000112 decision 3): {line}"
);
}
}
#[test]
fn renders_inline_filter_metrics() {
let m = ServerMetrics::new();
let mut snapshot = snap(3, 1, 0);
snapshot.inline.fallbacks_pool_exhausted = 2;
snapshot.inline.fallbacks_pool_filling = 5;
snapshot.inline.fallbacks_breaker_open = 1;
snapshot.inline.duration_buckets[0] = 1;
snapshot.inline.duration_count = 2;
snapshot.inline.duration_sum = Duration::from_micros(15);
let text = m.render(&snapshot, None, None, &[]);
assert!(text.contains("# HELP plecto_filter_inline_fallbacks_total Inline-eligible filter hook calls that fell back to the blocking pool, by reason."));
assert!(text.contains("# TYPE plecto_filter_inline_fallbacks_total counter"));
assert!(text.contains("plecto_filter_inline_fallbacks_total{reason=\"pool_exhausted\"} 2"));
assert!(text.contains("plecto_filter_inline_fallbacks_total{reason=\"pool_filling\"} 5"));
assert!(text.contains("plecto_filter_inline_fallbacks_total{reason=\"breaker_open\"} 1"));
assert!(text.contains("# TYPE plecto_filter_inline_duration_seconds histogram"));
assert!(text.contains("# HELP plecto_filter_inline_duration_seconds Inline filter execution duration in seconds."));
for bound in INLINE_DURATION_BUCKETS {
assert!(
text.contains(&format!(
"plecto_filter_inline_duration_seconds_bucket{{le=\"{bound}\"}}"
)),
"bucket for bound {bound} must be present in:\n{text}"
);
}
assert!(text.contains("plecto_filter_inline_duration_seconds_bucket{le=\"+Inf\"} 2"));
assert!(text.contains("plecto_filter_inline_duration_seconds_sum 0.000015"));
assert!(text.contains("plecto_filter_inline_duration_seconds_count 2"));
}
}