#[cfg(feature = "otel")]
mod enabled {
use core::fmt;
use std::sync::OnceLock;
use opentelemetry::KeyValue;
use opentelemetry::metrics::{Counter, Histogram, Meter};
const METER_NAME: &str = "keel-core";
const TARGET_KEY: &str = "keel.target";
struct Instruments {
attempts: Counter<u64>,
retries: Counter<u64>,
retry_backoff: Histogram<f64>,
cache_requests: Counter<u64>,
throttled: Counter<u64>,
rate_wait: Histogram<f64>,
breaker_transitions: Counter<u64>,
flow_resumes: Counter<u64>,
}
impl fmt::Debug for Instruments {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Instruments").finish_non_exhaustive()
}
}
impl Instruments {
fn build(meter: &Meter) -> Self {
Self {
attempts: meter
.u64_counter("keel.attempts")
.with_unit("{attempt}")
.with_description("Attempts executed (one per try, including the first)")
.build(),
retries: meter
.u64_counter("keel.retries")
.with_unit("{retry}")
.with_description("Retries scheduled after a retryable failed attempt")
.build(),
retry_backoff: meter
.f64_histogram("keel.retry.backoff")
.with_unit("ms")
.with_description("Backoff wait before each retry (post-jitter, ms)")
.build(),
cache_requests: meter
.u64_counter("keel.cache.requests")
.with_unit("{request}")
.with_description(
"Cache lookups; hit ratio = sum(keel.cache.hit=true) / sum(all)",
)
.build(),
throttled: meter
.u64_counter("keel.rate.throttled")
.with_unit("{call}")
.with_description("Calls delayed by the rate limiter (never failed)")
.build(),
rate_wait: meter
.f64_histogram("keel.rate.wait")
.with_unit("ms")
.with_description("Rate-limit delay per throttled call (ms)")
.build(),
breaker_transitions: meter
.u64_counter("keel.breaker.transitions")
.with_unit("{transition}")
.with_description(
"Breaker state changes (keel.breaker.transition: opened | half_open | closed)",
)
.build(),
flow_resumes: meter
.u64_counter("keel.flow.resumes")
.with_unit("{resume}")
.with_description(
"Tier 2 flow recoveries: re-entries of an incomplete flow (attempt >= 2)",
)
.build(),
}
}
}
static INSTRUMENTS: OnceLock<Instruments> = OnceLock::new();
pub fn bind_global_meter() {
let meter = opentelemetry::global::meter(METER_NAME);
let _already_bound = INSTRUMENTS.set(Instruments::build(&meter));
}
fn get() -> Option<&'static Instruments> {
INSTRUMENTS.get()
}
#[expect(
clippy::cast_precision_loss,
reason = "waits are milliseconds; f64 is exact for any wait a process could survive"
)]
fn ms(wait_ms: u64) -> f64 {
wait_ms as f64
}
pub fn record_attempt(target: &str) {
if let Some(i) = get() {
i.attempts
.add(1, &[KeyValue::new(TARGET_KEY, target.to_owned())]);
}
}
pub fn record_retry(target: &str, wait_ms: u64) {
if let Some(i) = get() {
let attrs = [KeyValue::new(TARGET_KEY, target.to_owned())];
i.retries.add(1, &attrs);
i.retry_backoff.record(ms(wait_ms), &attrs);
}
}
pub fn record_cache_request(target: &str, hit: bool) {
if let Some(i) = get() {
i.cache_requests.add(
1,
&[
KeyValue::new(TARGET_KEY, target.to_owned()),
KeyValue::new("keel.cache.hit", hit),
],
);
}
}
pub fn record_throttled(target: &str, wait_ms: u64) {
if let Some(i) = get() {
let attrs = [KeyValue::new(TARGET_KEY, target.to_owned())];
i.throttled.add(1, &attrs);
i.rate_wait.record(ms(wait_ms), &attrs);
}
}
pub fn record_breaker_transition(target: &str, transition: &'static str) {
if let Some(i) = get() {
i.breaker_transitions.add(
1,
&[
KeyValue::new(TARGET_KEY, target.to_owned()),
KeyValue::new("keel.breaker.transition", transition),
],
);
}
}
pub fn record_flow_resume(entrypoint: &str) {
if let Some(i) = get() {
i.flow_resumes.add(
1,
&[KeyValue::new("keel.flow.entrypoint", entrypoint.to_owned())],
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use opentelemetry_sdk::error::OTelSdkResult;
use opentelemetry_sdk::metrics::Temporality;
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData, ResourceMetrics};
use opentelemetry_sdk::metrics::exporter::PushMetricExporter;
use std::collections::BTreeMap;
use std::sync::Mutex;
use std::time::Duration;
#[derive(Clone, Default)]
struct DatapointCounts(std::sync::Arc<Mutex<BTreeMap<String, usize>>>);
impl fmt::Debug for DatapointCounts {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DatapointCounts").finish_non_exhaustive()
}
}
impl PushMetricExporter for DatapointCounts {
async fn export(&self, metrics: &ResourceMetrics) -> OTelSdkResult {
let mut counts = self.0.lock().expect("mutex poisoned");
for scope_metrics in metrics.scope_metrics() {
for metric in scope_metrics.metrics() {
let n = match metric.data() {
AggregatedMetrics::U64(MetricData::Sum(sum)) => {
sum.data_points().count()
}
AggregatedMetrics::F64(MetricData::Histogram(h)) => {
h.data_points().count()
}
_ => 0,
};
*counts.entry(metric.name().to_owned()).or_default() += n;
}
}
Ok(())
}
fn force_flush(&self) -> OTelSdkResult {
Ok(())
}
fn shutdown_with_timeout(&self, _timeout: Duration) -> OTelSdkResult {
Ok(())
}
fn temporality(&self) -> Temporality {
Temporality::Cumulative
}
}
#[test]
fn every_hook_emits_its_documented_datapoint() {
use opentelemetry::global;
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
let exporter = DatapointCounts::default();
let reader = PeriodicReader::builder(exporter.clone())
.with_interval(Duration::from_hours(1))
.build();
let provider = SdkMeterProvider::builder().with_reader(reader).build();
global::set_meter_provider(provider.clone());
bind_global_meter();
record_attempt("host:api.example.com");
record_retry("host:api.example.com", 250);
record_cache_request("llm:gpt", true);
record_cache_request("llm:gpt", false);
record_throttled("host:api.example.com", 40);
record_breaker_transition("host:api.example.com", "opened");
record_flow_resume("orders.checkout");
provider.force_flush().expect("force_flush");
let counts = exporter.0.lock().expect("mutex poisoned").clone();
for name in [
"keel.attempts",
"keel.retries",
"keel.retry.backoff",
"keel.cache.requests",
"keel.rate.throttled",
"keel.rate.wait",
"keel.breaker.transitions",
"keel.flow.resumes",
] {
assert!(
counts.get(name).is_some_and(|&n| n > 0),
"expected at least one {name} datapoint, got {counts:?}"
);
}
assert_eq!(counts["keel.cache.requests"], 2);
}
}
}
#[cfg(feature = "otel")]
pub(crate) use enabled::{
record_attempt, record_breaker_transition, record_cache_request, record_flow_resume,
record_retry, record_throttled,
};
#[cfg(feature = "otel")]
pub use enabled::bind_global_meter;
#[cfg(not(feature = "otel"))]
mod disabled {
pub fn record_attempt(_target: &str) {}
pub fn record_retry(_target: &str, _wait_ms: u64) {}
pub fn record_cache_request(_target: &str, _hit: bool) {}
pub fn record_throttled(_target: &str, _wait_ms: u64) {}
pub fn record_breaker_transition(_target: &str, _transition: &'static str) {}
pub fn record_flow_resume(_entrypoint: &str) {}
}
#[cfg(not(feature = "otel"))]
pub(crate) use disabled::*;