use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use prometheus::{
Encoder, HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge, Registry,
TextEncoder,
};
static HNSW_EVICTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
static HNSW_LAST_EVICTION_AT_NANOS: AtomicU64 = AtomicU64::new(0);
pub fn record_hnsw_eviction(count: u64, now_nanos: u64) {
HNSW_EVICTIONS_TOTAL.fetch_add(count, Ordering::Relaxed);
HNSW_LAST_EVICTION_AT_NANOS.store(now_nanos, Ordering::Relaxed);
let r = registry();
r.hnsw_evictions_total.inc_by(count);
#[allow(clippy::cast_possible_wrap)]
let nanos_i64 = i64::try_from(now_nanos).unwrap_or(i64::MAX);
r.hnsw_last_eviction_at_nanos.set(nanos_i64);
}
#[must_use]
pub fn hnsw_evictions_total() -> u64 {
HNSW_EVICTIONS_TOTAL.load(Ordering::Relaxed)
}
#[must_use]
pub fn hnsw_last_eviction_at_nanos() -> u64 {
HNSW_LAST_EVICTION_AT_NANOS.load(Ordering::Relaxed)
}
#[doc(hidden)]
pub fn reset_hnsw_eviction_counters_for_test() {
HNSW_EVICTIONS_TOTAL.store(0, Ordering::Relaxed);
HNSW_LAST_EVICTION_AT_NANOS.store(0, Ordering::Relaxed);
registry().hnsw_last_eviction_at_nanos.set(0);
}
#[allow(dead_code)]
pub struct Metrics {
pub registry: Registry,
pub store_total: IntCounterVec,
pub recall_total: IntCounterVec,
pub recall_latency_seconds: HistogramVec,
pub autonomy_hook_total: IntCounterVec,
pub contradiction_detected_total: IntCounter,
pub webhook_dispatched_total: IntCounter,
pub webhook_failed_total: IntCounter,
pub memories_gauge: IntGauge,
pub hnsw_size_gauge: IntGauge,
pub subscriptions_active_gauge: IntGauge,
pub curator_cycles_total: IntCounter,
pub curator_operations_total: IntCounterVec,
pub curator_cycle_duration_seconds: HistogramVec,
pub federation_fanout_dropped_total: IntCounterVec,
pub federation_fanout_retry_total: IntCounterVec,
pub federation_partial_quorum_total: IntCounter,
pub corrupt_provenance_rows_total: IntCounterVec,
pub auto_export_spawn_failed_total: IntCounter,
pub federation_push_dlq_depth: IntGauge,
pub federation_push_dlq_quarantined: IntCounter,
pub hnsw_evictions_total: IntCounter,
pub hnsw_last_eviction_at_nanos: IntGauge,
pub subscription_dlq_overflow_total: IntCounter,
pub federation_cred_verify_total: IntCounterVec,
pub federation_inbound_cred_total: IntCounterVec,
pub federation_cred_max_age_seconds: IntGauge,
pub federation_renewal_lag_seconds: IntGauge,
}
pub fn registry() -> &'static Metrics {
static HANDLE: OnceLock<Metrics> = OnceLock::new();
HANDLE.get_or_init(Metrics::new_or_panic)
}
impl Metrics {
fn new_or_panic() -> Self {
Self::try_new().expect("prometheus registry init failed")
}
#[allow(clippy::too_many_lines)]
pub(crate) fn try_new() -> prometheus::Result<Self> {
let registry = Registry::new();
let store_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_store_total",
"Total memory_store calls, labeled by tier and result.",
),
&["tier", "result"],
)?;
registry.register(Box::new(store_total.clone()))?;
let recall_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_recall_total",
"Total memory_recall calls, labeled by mode.",
),
&["mode"],
)?;
registry.register(Box::new(recall_total.clone()))?;
let recall_latency_seconds = HistogramVec::new(
HistogramOpts::new(
"ai_memory_recall_latency_seconds",
"Recall latency in seconds, labeled by mode.",
)
.buckets(vec![
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
]),
&["mode"],
)?;
registry.register(Box::new(recall_latency_seconds.clone()))?;
let autonomy_hook_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_autonomy_hook_total",
"Post-store autonomy hook invocations, labeled by kind and result.",
),
&["kind", "result"],
)?;
registry.register(Box::new(autonomy_hook_total.clone()))?;
let contradiction_detected_total = IntCounter::new(
"ai_memory_contradiction_detected_total",
"Count of contradictions the LLM hook confirmed.",
)?;
registry.register(Box::new(contradiction_detected_total.clone()))?;
let webhook_dispatched_total = IntCounter::new(
"ai_memory_webhook_dispatched_total",
"Total webhook deliveries attempted.",
)?;
registry.register(Box::new(webhook_dispatched_total.clone()))?;
let webhook_failed_total = IntCounter::new(
"ai_memory_webhook_failed_total",
"Webhook deliveries that failed after all retries.",
)?;
registry.register(Box::new(webhook_failed_total.clone()))?;
let memories_gauge = IntGauge::new(
"ai_memory_memories",
"Current count of non-archived memories.",
)?;
registry.register(Box::new(memories_gauge.clone()))?;
let hnsw_size_gauge = IntGauge::new(
"ai_memory_hnsw_size",
"Current HNSW vector index population.",
)?;
registry.register(Box::new(hnsw_size_gauge.clone()))?;
let subscriptions_active_gauge = IntGauge::new(
"ai_memory_subscriptions_active",
"Current count of active webhook subscriptions.",
)?;
registry.register(Box::new(subscriptions_active_gauge.clone()))?;
let curator_cycles_total = IntCounter::new(
"ai_memory_curator_cycles_total",
"Total curator sweep cycles completed.",
)?;
registry.register(Box::new(curator_cycles_total.clone()))?;
let curator_operations_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_curator_operations_total",
"Curator operations, labeled by kind (auto_tag|contradiction|persist) and result.",
),
&["kind", "result"],
)?;
registry.register(Box::new(curator_operations_total.clone()))?;
let curator_cycle_duration_seconds = HistogramVec::new(
HistogramOpts::new(
"ai_memory_curator_cycle_duration_seconds",
"Curator sweep cycle wall-clock duration, labeled by dry_run.",
)
.buckets(vec![
0.1,
0.5,
1.0,
5.0,
15.0,
60.0,
300.0,
900.0,
crate::SECS_PER_HOUR as f64,
]),
&["dry_run"],
)?;
registry.register(Box::new(curator_cycle_duration_seconds.clone()))?;
let federation_fanout_dropped_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_federation_fanout_dropped_total",
"Post-quorum fanout tasks whose outcome could not be observed. \
reason=shutdown|panic|join_error. Non-zero indicates mesh divergence risk.",
),
&["reason"],
)?;
registry.register(Box::new(federation_fanout_dropped_total.clone()))?;
let federation_fanout_retry_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_federation_fanout_retry_total",
"Peer POSTs that hit a transient failure on first attempt and \
were retried once via the Idempotency-Key path. \
outcome=ok|fail|id_drift. Non-zero ok indicates the retry \
recovered a row that would otherwise be missing on a peer.",
),
&["outcome"],
)?;
registry.register(Box::new(federation_fanout_retry_total.clone()))?;
let federation_partial_quorum_total = IntCounter::new(
"ai_memory_federation_partial_quorum_total",
"Quorum writes that succeeded (W met) but where at least one \
configured peer did not ack inside the deadline.",
)?;
registry.register(Box::new(federation_partial_quorum_total.clone()))?;
let corrupt_provenance_rows_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_corrupt_provenance_rows_total",
"Memory rows whose Form 4 fact-provenance JSON columns \
failed to deserialise and were silently defaulted. \
Non-zero indicates schema drift, writer-side corruption, \
or a migration leaving malformed JSON.",
),
&["column"],
)?;
registry.register(Box::new(corrupt_provenance_rows_total.clone()))?;
let auto_export_spawn_failed_total = IntCounter::new(
"ai_memory_auto_export_spawn_failed_total",
"Detached post_reflect.auto_export worker invocations whose \
outcome was a panic or returned Err. Non-zero means at \
least one reflection was committed to the DB but its \
on-disk markdown/json artefact did not land — operators \
use this to alert on otherwise-silent disk-write failures.",
)?;
registry.register(Box::new(auto_export_spawn_failed_total.clone()))?;
let federation_push_dlq_depth = IntGauge::new(
"ai_memory_federation_push_dlq_depth",
"Current count of pending federation_push_dlq rows \
(replayed_at IS NULL). Refreshed on every replay tick. \
Non-zero sustained depth indicates one or more peers are \
persistently unreachable; healthy meshes drain back to 0 \
within one replay interval after peer recovery.",
)?;
registry.register(Box::new(federation_push_dlq_depth.clone()))?;
let federation_push_dlq_quarantined = IntCounter::new(
"ai_memory_federation_push_dlq_quarantined_total",
"Monotonic counter of federation_push_dlq rows the replay \
worker has skipped because their attempt_count exceeded \
MAX_REPLAY_ATTEMPTS (currently 100). Non-zero sustained \
rate indicates poison-message rows that need operator \
intervention via `ai-memory federation dlq drain \
--quarantined`. Pre-#1032 the worker retried these \
forever, amplifying network load against rejecting peers.",
)?;
registry.register(Box::new(federation_push_dlq_quarantined.clone()))?;
let hnsw_evictions_total = IntCounter::new(
"ai_memory_hnsw_evictions_total",
"Cumulative HNSW oldest-eviction count since process start. \
Non-zero indicates the in-memory vector index has hit \
MAX_ENTRIES and dropped older embeddings; recall quality \
may have degraded for evicted ids until they are \
re-inserted on next access.",
)?;
registry.register(Box::new(hnsw_evictions_total.clone()))?;
let hnsw_last_eviction_at_nanos = IntGauge::new(
"ai_memory_hnsw_last_eviction_at_nanos",
"Wall-clock UNIX nanoseconds of the most recent HNSW \
eviction (0 if none). Capabilities derives \
hnsw.evicted_recently from this with a 60s rolling window.",
)?;
registry.register(Box::new(hnsw_last_eviction_at_nanos.clone()))?;
let subscription_dlq_overflow_total = IntCounter::new(
"ai_memory_subscription_dlq_overflow_total",
"Monotonic counter of subscription_dlq inserts refused \
because the per-subscription DLQ depth had already hit \
MAX_SUBSCRIPTION_DLQ_ROWS (10_000). Non-zero indicates a \
hostile or persistently-broken webhook target that would \
otherwise fill the operator's disk with quarantined rows. \
Operators drain the queue via `ai-memory subscription dlq \
drain <subscription_id>` before resetting.",
)?;
registry.register(Box::new(subscription_dlq_overflow_total.clone()))?;
let federation_cred_verify_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_federation_cred_verify_total",
"Federation credential-verification outcomes on the \
receiver path, labeled result (ok|fail). \
verify-failure-rate SLO = fail / (ok + fail). Non-zero \
sustained fail rate means peers present credentials the \
local trust bundle cannot verify (expired leaf, revoked \
issuer, clock skew, or a chain that fails to anchor).",
),
&["result"],
)?;
registry.register(Box::new(federation_cred_verify_total.clone()))?;
let federation_inbound_cred_total = IntCounterVec::new(
prometheus::Opts::new(
"ai_memory_federation_inbound_cred_total",
"Inbound federation requests bucketed by whether they \
presented a signed credential, labeled presence \
(signed|unsigned). signed-vs-unsigned-ratio SLO = \
signed / (signed + unsigned). Climbs toward 1.0 as \
peers upgrade to credential-presenting builds.",
),
&["presence"],
)?;
registry.register(Box::new(federation_inbound_cred_total.clone()))?;
let federation_cred_max_age_seconds = IntGauge::new(
"ai_memory_federation_cred_max_age_seconds",
"Age in seconds of the local outbound leaf credential \
(now - issued_at), refreshed on every renewal tick. \
max-cred-age SLO alerts when this approaches the leaf TTL \
— a credential aging past its TTL without a renewal means \
the refresh worker has stalled and outbound sync will \
start failing peer verification.",
)?;
registry.register(Box::new(federation_cred_max_age_seconds.clone()))?;
let federation_renewal_lag_seconds = IntGauge::new(
"ai_memory_federation_renewal_lag_seconds",
"Seconds since the last successful outbound-credential \
renewal (now - last-renew wall clock), refreshed on every \
renewal tick. renewal-lag SLO alerts when this exceeds the \
configured refresh interval by a safety margin: a lag \
larger than the interval means renewals are silently \
failing even though the worker thread is still alive.",
)?;
registry.register(Box::new(federation_renewal_lag_seconds.clone()))?;
Ok(Self {
registry,
store_total,
recall_total,
recall_latency_seconds,
autonomy_hook_total,
contradiction_detected_total,
webhook_dispatched_total,
webhook_failed_total,
memories_gauge,
hnsw_size_gauge,
subscriptions_active_gauge,
curator_cycles_total,
curator_operations_total,
curator_cycle_duration_seconds,
federation_fanout_dropped_total,
federation_fanout_retry_total,
federation_partial_quorum_total,
corrupt_provenance_rows_total,
auto_export_spawn_failed_total,
federation_push_dlq_depth,
federation_push_dlq_quarantined,
hnsw_evictions_total,
hnsw_last_eviction_at_nanos,
subscription_dlq_overflow_total,
federation_cred_verify_total,
federation_inbound_cred_total,
federation_cred_max_age_seconds,
federation_renewal_lag_seconds,
})
}
}
pub fn record_subscription_dlq_overflow() {
registry().subscription_dlq_overflow_total.inc();
}
#[must_use]
pub fn subscription_dlq_overflow_count() -> u64 {
registry().subscription_dlq_overflow_total.get()
}
pub fn record_federation_cred_verify(ok: bool) {
let result = if ok { "ok" } else { "fail" };
registry()
.federation_cred_verify_total
.with_label_values(&[result])
.inc();
}
#[must_use]
pub fn federation_cred_verify_count(result: &str) -> u64 {
registry()
.federation_cred_verify_total
.with_label_values(&[result])
.get()
}
pub fn record_federation_inbound_cred(signed: bool) {
let presence = if signed { "signed" } else { "unsigned" };
registry()
.federation_inbound_cred_total
.with_label_values(&[presence])
.inc();
}
#[must_use]
pub fn federation_inbound_cred_count(presence: &str) -> u64 {
registry()
.federation_inbound_cred_total
.with_label_values(&[presence])
.get()
}
pub fn set_federation_cred_max_age_seconds(secs: i64) {
registry().federation_cred_max_age_seconds.set(secs);
}
pub fn set_federation_renewal_lag_seconds(secs: i64) {
registry().federation_renewal_lag_seconds.set(secs);
}
pub fn record_corrupt_provenance(column: &str) {
registry()
.corrupt_provenance_rows_total
.with_label_values(&[column])
.inc();
}
pub fn record_auto_export_spawn_failed() {
registry().auto_export_spawn_failed_total.inc();
}
#[must_use]
pub fn auto_export_spawn_failed_count() -> u64 {
registry().auto_export_spawn_failed_total.get()
}
#[must_use]
pub fn render() -> String {
let encoder = TextEncoder::new();
let mut buf = Vec::new();
let _ = encoder.encode(®istry().registry.gather(), &mut buf);
String::from_utf8(buf).unwrap_or_default()
}
#[allow(dead_code)]
pub fn record_store(tier: &str, ok: bool) {
let result = if ok { "ok" } else { "err" };
registry()
.store_total
.with_label_values(&[tier, result])
.inc();
}
#[allow(dead_code)]
pub fn record_recall(mode: &str, latency_seconds: f64) {
registry().recall_total.with_label_values(&[mode]).inc();
registry()
.recall_latency_seconds
.with_label_values(&[mode])
.observe(latency_seconds);
}
#[allow(dead_code)]
pub fn record_autonomy_hook(kind: &str, ok: bool) {
let result = if ok { "ok" } else { "err" };
registry()
.autonomy_hook_total
.with_label_values(&[kind, result])
.inc();
}
#[allow(dead_code)]
pub fn curator_cycle_completed(
operations_attempted: usize,
auto_tagged: usize,
contradictions_found: usize,
errors: usize,
) {
let r = registry();
r.curator_cycles_total.inc();
if auto_tagged > 0 {
r.curator_operations_total
.with_label_values(&["auto_tag", "ok"])
.inc_by(auto_tagged as u64);
}
if contradictions_found > 0 {
r.curator_operations_total
.with_label_values(&["contradiction", "ok"])
.inc_by(contradictions_found as u64);
}
let failed = operations_attempted.saturating_sub(auto_tagged + contradictions_found);
if failed > 0 || errors > 0 {
r.curator_operations_total
.with_label_values(&["any", "err"])
.inc_by(errors as u64);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Tier;
#[test]
fn registry_is_singleton() {
let r1 = registry();
let r2 = registry();
assert!(std::ptr::eq(std::ptr::from_ref(r1), std::ptr::from_ref(r2)));
}
#[test]
fn render_includes_registered_names() {
record_store(Tier::Short.as_str(), true);
record_recall("hybrid", 0.042);
record_autonomy_hook("auto_tag", true);
registry().contradiction_detected_total.inc();
registry().webhook_dispatched_total.inc();
registry().memories_gauge.set(42);
registry().hnsw_size_gauge.set(42);
registry().subscriptions_active_gauge.set(3);
registry().federation_push_dlq_depth.set(0);
record_federation_cred_verify(true);
record_federation_inbound_cred(true);
set_federation_cred_max_age_seconds(0);
set_federation_renewal_lag_seconds(0);
let text = render();
for name in [
"ai_memory_store_total",
"ai_memory_recall_total",
"ai_memory_recall_latency_seconds",
"ai_memory_autonomy_hook_total",
"ai_memory_contradiction_detected_total",
"ai_memory_webhook_dispatched_total",
"ai_memory_webhook_failed_total",
"ai_memory_memories",
"ai_memory_hnsw_size",
"ai_memory_subscriptions_active",
"ai_memory_federation_push_dlq_depth",
"ai_memory_federation_cred_verify_total",
"ai_memory_federation_inbound_cred_total",
"ai_memory_federation_cred_max_age_seconds",
"ai_memory_federation_renewal_lag_seconds",
] {
assert!(text.contains(name), "/metrics missing {name}\n\n{text}");
}
}
#[test]
fn federation_cred_verify_labels_outcome() {
let before_ok = federation_cred_verify_count("ok");
let before_fail = federation_cred_verify_count("fail");
record_federation_cred_verify(true);
record_federation_cred_verify(false);
assert!(federation_cred_verify_count("ok") >= before_ok + 1);
assert!(federation_cred_verify_count("fail") >= before_fail + 1);
let text = render();
assert!(text.contains("ai_memory_federation_cred_verify_total{result=\"ok\"}"));
assert!(text.contains("ai_memory_federation_cred_verify_total{result=\"fail\"}"));
}
#[test]
fn federation_inbound_cred_labels_presence() {
let before_signed = federation_inbound_cred_count("signed");
let before_unsigned = federation_inbound_cred_count("unsigned");
record_federation_inbound_cred(true);
record_federation_inbound_cred(false);
assert!(federation_inbound_cred_count("signed") >= before_signed + 1);
assert!(federation_inbound_cred_count("unsigned") >= before_unsigned + 1);
}
#[test]
fn federation_cred_age_and_lag_gauges_settable() {
set_federation_cred_max_age_seconds(1234);
set_federation_renewal_lag_seconds(56);
assert_eq!(registry().federation_cred_max_age_seconds.get(), 1234);
assert_eq!(registry().federation_renewal_lag_seconds.get(), 56);
}
#[test]
fn record_store_labels_tier() {
record_store(Tier::Long.as_str(), true);
let text = render();
assert!(text.contains("ai_memory_store_total{result=\"ok\",tier=\"long\"}"));
}
#[test]
fn curator_cycle_completed_increments_total() {
let before = registry().curator_cycles_total.get();
curator_cycle_completed(0, 0, 0, 0);
let after = registry().curator_cycles_total.get();
assert!(
after >= before + 1,
"curator_cycles_total did not advance (before={before}, after={after})"
);
}
#[test]
fn curator_cycle_completed_records_auto_tag_ok() {
curator_cycle_completed(5, 3, 0, 0);
let text = render();
assert!(
text.contains("ai_memory_curator_operations_total"),
"curator_operations_total counter missing from /metrics output"
);
}
#[test]
fn curator_cycle_completed_records_contradiction_ok() {
curator_cycle_completed(2, 0, 2, 0);
let text = render();
assert!(text.contains("ai_memory_curator_operations_total"));
}
#[test]
fn curator_cycle_completed_records_errors() {
curator_cycle_completed(5, 2, 1, 1);
let text = render();
assert!(text.contains("ai_memory_curator_operations_total"));
}
#[test]
fn curator_cycle_completed_with_zero_args_is_safe() {
let before = registry().curator_cycles_total.get();
curator_cycle_completed(0, 0, 0, 0);
let after = registry().curator_cycles_total.get();
assert!(after >= before + 1);
}
#[test]
fn record_store_err_path() {
record_store(Tier::Short.as_str(), false);
let text = render();
assert!(text.contains("ai_memory_store_total{result=\"err\",tier=\"short\""));
}
#[test]
fn record_recall_emits_latency_histogram() {
record_recall("keyword", 0.5);
let text = render();
assert!(text.contains("ai_memory_recall_total{mode=\"keyword\""));
assert!(text.contains("ai_memory_recall_latency_seconds"));
}
#[test]
fn record_autonomy_hook_err_path() {
record_autonomy_hook("contradiction", false);
let text = render();
assert!(
text.contains("ai_memory_autonomy_hook_total{kind=\"contradiction\",result=\"err\"")
);
}
#[test]
fn render_emits_help_and_type_lines() {
record_store(Tier::Mid.as_str(), true);
let text = render();
assert!(text.contains("# HELP ai_memory_store_total"));
assert!(text.contains("# TYPE ai_memory_store_total counter"));
}
#[test]
fn fanout_dropped_counter_increments() {
registry()
.federation_fanout_dropped_total
.with_label_values(&["shutdown"])
.inc();
let text = render();
assert!(text.contains("ai_memory_federation_fanout_dropped_total{reason=\"shutdown\""));
}
#[test]
fn fanout_retry_counter_outcome_labels() {
for outcome in ["ok", "fail", "id_drift"] {
registry()
.federation_fanout_retry_total
.with_label_values(&[outcome])
.inc();
}
let text = render();
assert!(text.contains("ai_memory_federation_fanout_retry_total"));
}
#[test]
fn curator_cycle_duration_histogram_buckets() {
registry()
.curator_cycle_duration_seconds
.with_label_values(&["false"])
.observe(0.42);
let text = render();
assert!(text.contains("ai_memory_curator_cycle_duration_seconds"));
}
#[test]
fn try_new_builds_a_fresh_metrics_handle() {
let m = super::Metrics::try_new().expect("fresh registry must succeed");
m.store_total
.with_label_values(&[Tier::Short.as_str(), "ok"])
.inc();
m.recall_total.with_label_values(&["hybrid"]).inc();
m.recall_latency_seconds
.with_label_values(&["hybrid"])
.observe(0.001);
m.autonomy_hook_total.with_label_values(&["x", "ok"]).inc();
m.contradiction_detected_total.inc();
m.webhook_dispatched_total.inc();
m.webhook_failed_total.inc();
m.memories_gauge.set(1);
m.hnsw_size_gauge.set(1);
m.subscriptions_active_gauge.set(1);
m.curator_cycles_total.inc();
m.curator_operations_total
.with_label_values(&["auto_tag", "ok"])
.inc();
m.curator_cycle_duration_seconds
.with_label_values(&["true"])
.observe(1.0);
m.federation_fanout_dropped_total
.with_label_values(&["panic"])
.inc();
m.federation_fanout_retry_total
.with_label_values(&["ok"])
.inc();
m.federation_partial_quorum_total.inc();
m.auto_export_spawn_failed_total.inc();
}
#[test]
fn try_new_can_build_two_isolated_registries() {
let a = super::Metrics::try_new().expect("first");
let b = super::Metrics::try_new().expect("second");
a.store_total
.with_label_values(&[Tier::Short.as_str(), "ok"])
.inc();
b.store_total
.with_label_values(&[Tier::Short.as_str(), "ok"])
.inc();
let mut buf_a = Vec::new();
let mut buf_b = Vec::new();
let enc = TextEncoder::new();
enc.encode(&a.registry.gather(), &mut buf_a).unwrap();
enc.encode(&b.registry.gather(), &mut buf_b).unwrap();
assert!(String::from_utf8_lossy(&buf_a).contains("ai_memory_store_total"));
assert!(String::from_utf8_lossy(&buf_b).contains("ai_memory_store_total"));
}
#[test]
fn record_auto_export_spawn_failed_increments_singleton() {
let before = auto_export_spawn_failed_count();
record_auto_export_spawn_failed();
let after = auto_export_spawn_failed_count();
assert!(
after >= before + 1,
"auto_export_spawn_failed_total did not advance \
(before={before}, after={after})"
);
let text = render();
assert!(
text.contains("ai_memory_auto_export_spawn_failed_total"),
"/metrics output missing auto_export counter\n\n{text}"
);
}
#[test]
fn curator_cycle_completed_no_progress_branch_skips_err_increment() {
let before = registry().curator_cycles_total.get();
curator_cycle_completed(0, 0, 0, 0);
let after = registry().curator_cycles_total.get();
assert!(after >= before + 1);
}
}