use std::sync::OnceLock;
use prometheus::{HistogramOpts, HistogramVec, IntCounterVec, Opts, Registry, TextEncoder};
const EVENT_INGEST_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,
];
fn registry() -> &'static Registry {
static REG: OnceLock<Registry> = OnceLock::new();
REG.get_or_init(Registry::new)
}
pub fn events_ingested_total() -> &'static IntCounterVec {
static M: OnceLock<IntCounterVec> = OnceLock::new();
M.get_or_init(|| {
let counter = IntCounterVec::new(
Opts::new(
"noetl_events_ingested_total",
"Total events accepted by POST /api/events (incremented once per handler call, whether the body persisted or errored).",
),
&["event_type", "status"],
)
.expect("static counter spec must be valid");
registry()
.register(Box::new(counter.clone()))
.expect("counter registration must succeed");
counter
})
}
pub fn event_ingest_duration_seconds() -> &'static HistogramVec {
static M: OnceLock<HistogramVec> = OnceLock::new();
M.get_or_init(|| {
let hist = HistogramVec::new(
HistogramOpts::new(
"noetl_event_ingest_duration_seconds",
"Wall-clock time spent inside POST /api/events.",
)
.buckets(EVENT_INGEST_BUCKETS.to_vec()),
&["event_type"],
)
.expect("static histogram spec must be valid");
registry()
.register(Box::new(hist.clone()))
.expect("histogram registration must succeed");
hist
})
}
pub fn record_event_ingest(event_type: &str, status: &str, duration_seconds: f64) {
events_ingested_total()
.with_label_values(&[event_type, status])
.inc();
event_ingest_duration_seconds()
.with_label_values(&[event_type])
.observe(duration_seconds);
}
pub mod endpoint {
pub const CATALOG_REGISTER: &str = "catalog_register";
pub const CREDENTIALS_UPSERT: &str = "credentials_upsert";
pub const KEYCHAIN_SET: &str = "keychain_set";
pub const RUNTIME_REGISTER: &str = "runtime_register";
pub const RUNTIME_HEARTBEAT: &str = "runtime_heartbeat";
}
pub fn write_requests_total() -> &'static IntCounterVec {
static M: OnceLock<IntCounterVec> = OnceLock::new();
M.get_or_init(|| {
let counter = IntCounterVec::new(
Opts::new(
"noetl_write_requests_total",
"Total POST requests to write endpoints other than /api/events (counted once per handler call, Ok or Err).",
),
&["endpoint", "status"],
)
.expect("static counter spec must be valid");
registry()
.register(Box::new(counter.clone()))
.expect("counter registration must succeed");
counter
})
}
pub fn write_request_duration_seconds() -> &'static HistogramVec {
static M: OnceLock<HistogramVec> = OnceLock::new();
M.get_or_init(|| {
let hist = HistogramVec::new(
HistogramOpts::new(
"noetl_write_request_duration_seconds",
"Wall-clock time spent inside POST write endpoints (other than /api/events).",
)
.buckets(EVENT_INGEST_BUCKETS.to_vec()),
&["endpoint"],
)
.expect("static histogram spec must be valid");
registry()
.register(Box::new(hist.clone()))
.expect("histogram registration must succeed");
hist
})
}
pub fn record_write_request(endpoint: &str, status: &str, duration_seconds: f64) {
write_requests_total()
.with_label_values(&[endpoint, status])
.inc();
write_request_duration_seconds()
.with_label_values(&[endpoint])
.observe(duration_seconds);
}
pub fn gather_text() -> Result<String, prometheus::Error> {
let encoder = TextEncoder::new();
let metric_families = registry().gather();
encoder.encode_to_string(&metric_families)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_initializes_once() {
let a = registry() as *const Registry;
let b = registry() as *const Registry;
assert_eq!(a, b, "registry() must return the same instance");
}
#[test]
fn counter_increments_by_label_set() {
events_ingested_total()
.with_label_values(&["test.counter_increments", "ok"])
.inc();
events_ingested_total()
.with_label_values(&["test.counter_increments", "ok"])
.inc();
let value = events_ingested_total()
.with_label_values(&["test.counter_increments", "ok"])
.get();
assert!(value >= 2, "expected at least 2 increments, got {value}");
}
#[test]
fn histogram_observes_duration() {
event_ingest_duration_seconds()
.with_label_values(&["test.histogram_observes"])
.observe(0.123);
let text = gather_text().expect("gather_text must succeed");
assert!(
text.contains("test.histogram_observes"),
"expected histogram label in text:\n{text}"
);
}
#[test]
fn gather_text_contains_metric_names() {
record_event_ingest("test.gather_text", "ok", 0.05);
let text = gather_text().expect("gather_text must succeed");
assert!(
text.contains("noetl_events_ingested_total"),
"expected counter name in text:\n{text}"
);
assert!(
text.contains("noetl_event_ingest_duration_seconds"),
"expected histogram name in text:\n{text}"
);
}
#[test]
fn record_event_ingest_handles_both_statuses() {
record_event_ingest("test.both_statuses", "ok", 0.01);
record_event_ingest("test.both_statuses", "error", 0.02);
let text = gather_text().expect("gather_text must succeed");
assert!(text.contains("test.both_statuses"));
assert!(
text.contains("status=\"ok\""),
"expected status=ok label in text:\n{text}"
);
assert!(
text.contains("status=\"error\""),
"expected status=error label in text:\n{text}"
);
}
#[test]
fn write_request_counter_increments_by_label_set() {
record_write_request("test.write.counter", "ok", 0.01);
record_write_request("test.write.counter", "ok", 0.02);
let value = write_requests_total()
.with_label_values(&["test.write.counter", "ok"])
.get();
assert!(value >= 2, "expected at least 2 increments, got {value}");
}
#[test]
fn write_request_metric_names_appear_in_text() {
record_write_request("test.write.text", "ok", 0.05);
let text = gather_text().expect("gather_text must succeed");
assert!(
text.contains("noetl_write_requests_total"),
"expected counter name in text:\n{text}"
);
assert!(
text.contains("noetl_write_request_duration_seconds"),
"expected histogram name in text:\n{text}"
);
assert!(text.contains("endpoint=\"test.write.text\""));
}
#[test]
fn endpoint_constants_are_used_consistently() {
let names = [
endpoint::CATALOG_REGISTER,
endpoint::CREDENTIALS_UPSERT,
endpoint::KEYCHAIN_SET,
endpoint::RUNTIME_REGISTER,
endpoint::RUNTIME_HEARTBEAT,
];
assert_eq!(names.iter().collect::<std::collections::HashSet<_>>().len(), names.len());
assert!(names.iter().all(|n| !n.is_empty()));
}
}