use std::collections::HashMap;
use std::sync::Mutex;
use opentelemetry::KeyValue;
use opentelemetry::metrics::{Gauge, Meter};
use subms::{ObservationCtx, SubMsStageKind};
use crate::bridge::{attributes_from_ctx, histogram_boundaries};
pub const DEFAULT_RESERVOIR_K: usize = 5;
pub const EXEMPLAR_GAUGE_NAME: &str = "subms.exemplars";
#[derive(Clone, Debug)]
pub struct Exemplar {
pub stage: String,
pub kind: SubMsStageKind,
pub bucket_upper_seconds: f64,
pub ns: u64,
pub attributes: Vec<KeyValue>,
}
pub struct ExemplarReservoir {
k: usize,
buckets: Mutex<HashMap<(String, usize), Vec<Exemplar>>>,
}
impl Default for ExemplarReservoir {
fn default() -> Self {
Self::with_capacity(DEFAULT_RESERVOIR_K)
}
}
impl ExemplarReservoir {
pub fn new() -> Self {
Self::default()
}
pub fn with_capacity(k: usize) -> Self {
assert!(k > 0, "reservoir K must be > 0");
Self {
k,
buckets: Mutex::new(HashMap::new()),
}
}
pub fn capacity(&self) -> usize {
self.k
}
pub fn offer(&self, ctx: &ObservationCtx<'_>, ns: u64) -> bool {
let bucket_idx = bucket_index_for(ctx.stage_kind, ns);
let bucket_upper = bucket_upper_seconds(ctx.stage_kind, bucket_idx);
let mut map = self.buckets.lock().expect("exemplar reservoir poisoned");
let key = (ctx.stage.to_string(), bucket_idx);
let entries = map.entry(key).or_default();
if entries.len() < self.k {
entries.push(Exemplar {
stage: ctx.stage.to_string(),
kind: ctx.stage_kind,
bucket_upper_seconds: bucket_upper,
ns,
attributes: attributes_from_ctx(ctx),
});
entries.sort_by_key(|e| e.ns);
return true;
}
if ns > entries[0].ns {
entries[0] = Exemplar {
stage: ctx.stage.to_string(),
kind: ctx.stage_kind,
bucket_upper_seconds: bucket_upper,
ns,
attributes: attributes_from_ctx(ctx),
};
entries.sort_by_key(|e| e.ns);
return true;
}
false
}
pub fn snapshot(&self) -> Vec<Exemplar> {
let map = self.buckets.lock().expect("exemplar reservoir poisoned");
let mut out = Vec::new();
for entries in map.values() {
out.extend(entries.iter().cloned());
}
out
}
pub fn clear(&self) {
self.buckets
.lock()
.expect("exemplar reservoir poisoned")
.clear();
}
pub fn publish(&self, meter: &Meter) {
let gauge: Gauge<u64> = meter
.u64_gauge(EXEMPLAR_GAUGE_NAME)
.with_description("Slow-sample exemplars retained per (stage, bucket).")
.build();
for ex in self.snapshot() {
let mut attrs = ex.attributes.clone();
attrs.push(KeyValue::new(
"subms.exemplar.bucket_upper_s",
ex.bucket_upper_seconds,
));
attrs.push(KeyValue::new("subms.exemplar.ns", ex.ns as i64));
gauge.record(ex.ns, &attrs);
}
}
}
fn bucket_index_for(kind: SubMsStageKind, ns: u64) -> usize {
let secs = ns as f64 / 1e9;
let bounds = histogram_boundaries(kind);
for (idx, bound) in bounds.iter().enumerate() {
if secs <= *bound {
return idx;
}
}
bounds.len()
}
fn bucket_upper_seconds(kind: SubMsStageKind, idx: usize) -> f64 {
let bounds = histogram_boundaries(kind);
if idx < bounds.len() {
bounds[idx]
} else {
f64::INFINITY
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx<'a>(stage: &'a str) -> ObservationCtx<'a> {
ObservationCtx {
workload: "wl",
lang: "rust",
stage,
stage_kind: SubMsStageKind::HotPath,
}
}
#[test]
fn bucket_index_partitions_by_kind() {
assert_eq!(bucket_index_for(SubMsStageKind::HotPath, 10), 0);
assert_eq!(bucket_index_for(SubMsStageKind::HotPath, 100), 1);
let hot_overflow = bucket_index_for(SubMsStageKind::HotPath, 2_000_000);
assert_eq!(
hot_overflow,
histogram_boundaries(SubMsStageKind::HotPath).len()
);
}
#[test]
fn reservoir_keeps_slowest_k() {
let r = ExemplarReservoir::with_capacity(3);
for ns in [1u64, 2, 3, 4, 5] {
r.offer(&ctx("put"), ns);
}
let snap = r.snapshot();
let kept: Vec<u64> = snap.iter().map(|e| e.ns).collect();
assert_eq!(kept, vec![3, 4, 5], "slowest 3 kept");
}
}