Skip to main content

appcore_ops/
metrics.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: metrics.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/06/02 17:10:40 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Minimal in-memory counters for local runtime diagnostics.
12
13use parking_lot::Mutex;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16use std::sync::Arc;
17
18/// Maximum UTF-8 bytes accepted in one process-local metric name.
19pub const MAX_METRIC_NAME_BYTES: usize = 128;
20/// Maximum distinct counters retained by one process-local registry.
21pub const MAX_IN_MEMORY_METRICS: usize = 4_096;
22/// Absolute aggregate retained-byte ceiling for process-local metric names.
23pub const MAX_IN_MEMORY_METRIC_BYTES: usize = 1024 * 1024;
24const METRIC_FIXED_BYTES: usize =
25    std::mem::size_of::<(Arc<str>, u64)>() + std::mem::size_of::<usize>() * 4;
26
27/// Named monotonic counter snapshot.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct MetricCounter {
30    /// Stable metric name.
31    pub name: String,
32    /// Current counter value.
33    pub value: u64,
34}
35
36/// Immutable shared view of sorted process-local counters.
37#[derive(Debug, Clone, Default)]
38pub struct MetricSnapshot {
39    counters: Arc<BTreeMap<Arc<str>, u64>>,
40}
41
42impl MetricSnapshot {
43    /// Returns the number of counters in the snapshot.
44    pub fn len(&self) -> usize {
45        self.counters.len()
46    }
47
48    /// Reports whether the snapshot contains no counters.
49    pub fn is_empty(&self) -> bool {
50        self.counters.is_empty()
51    }
52
53    /// Iterates over sorted borrowed names and their values.
54    pub fn iter(&self) -> impl Iterator<Item = (&str, u64)> {
55        self.counters
56            .iter()
57            .map(|(name, value)| (name.as_ref(), *value))
58    }
59}
60
61/// Point-in-time capacity and retained-memory pressure for metric names.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
63pub struct MetricRegistryPressure {
64    /// Current distinct counter count.
65    pub entries: usize,
66    /// Configured distinct counter ceiling.
67    pub max_entries: usize,
68    /// Estimated bytes retained by counter keys and map nodes.
69    pub used_bytes: usize,
70    /// Highest estimated retained byte count observed since creation.
71    pub peak_bytes: usize,
72    /// Configured aggregate retained-byte ceiling.
73    pub max_bytes: usize,
74    /// New names rejected because the counter ceiling was full.
75    pub count_rejections: u64,
76    /// New names rejected because the byte ceiling was full.
77    pub byte_rejections: u64,
78    /// Empty, oversized or NUL-containing names rejected at admission.
79    pub name_rejections: u64,
80}
81
82#[derive(Debug)]
83struct MetricState {
84    counters: Arc<BTreeMap<Arc<str>, u64>>,
85    pressure: MetricRegistryPressure,
86}
87
88/// Process-local monotonic counter registry.
89#[derive(Debug)]
90pub struct InMemoryMetrics {
91    state: Mutex<MetricState>,
92}
93
94impl InMemoryMetrics {
95    /// Creates an empty counter registry.
96    pub fn new() -> Self {
97        Self::with_limits(MAX_IN_MEMORY_METRICS, MAX_IN_MEMORY_METRIC_BYTES)
98    }
99
100    /// Creates a registry under explicit limits clamped to public safety ceilings.
101    pub fn with_limits(max_entries: usize, max_bytes: usize) -> Self {
102        let max_entries = max_entries.clamp(1, MAX_IN_MEMORY_METRICS);
103        let max_bytes = max_bytes.clamp(1, MAX_IN_MEMORY_METRIC_BYTES);
104        Self {
105            state: Mutex::new(MetricState {
106                counters: Arc::new(BTreeMap::new()),
107                pressure: MetricRegistryPressure {
108                    max_entries,
109                    max_bytes,
110                    ..MetricRegistryPressure::default()
111                },
112            }),
113        }
114    }
115
116    /// Saturating-increments a named counter and returns its new value.
117    ///
118    /// Invalid or over-capacity new names return zero and increment the
119    /// corresponding [`Self::pressure`] rejection counter. Existing admitted
120    /// counters can always be incremented.
121    pub fn increment(&self, name: &str) -> u64 {
122        self.try_increment(name).unwrap_or(0)
123    }
124
125    /// Attempts to increment a counter under the configured admission bounds.
126    pub fn try_increment(&self, name: &str) -> Option<u64> {
127        let mut state = self.state.lock();
128        if !metric_name_is_valid(name) {
129            state.pressure.name_rejections = state.pressure.name_rejections.saturating_add(1);
130            return None;
131        }
132        if state.counters.contains_key(name) {
133            if let Some(value) = Arc::make_mut(&mut state.counters).get_mut(name) {
134                *value = value.saturating_add(1);
135                return Some(*value);
136            }
137            return None;
138        }
139        if state.pressure.entries >= state.pressure.max_entries {
140            state.pressure.count_rejections = state.pressure.count_rejections.saturating_add(1);
141            return None;
142        }
143        let retained_bytes = metric_retained_bytes(name);
144        if state.pressure.used_bytes.saturating_add(retained_bytes) > state.pressure.max_bytes {
145            state.pressure.byte_rejections = state.pressure.byte_rejections.saturating_add(1);
146            return None;
147        }
148        Arc::make_mut(&mut state.counters).insert(Arc::from(name), 1);
149        state.pressure.entries = state.pressure.entries.saturating_add(1);
150        state.pressure.used_bytes = state.pressure.used_bytes.saturating_add(retained_bytes);
151        state.pressure.peak_bytes = state.pressure.peak_bytes.max(state.pressure.used_bytes);
152        Some(1)
153    }
154
155    /// Returns counters ordered by name.
156    pub fn snapshot(&self) -> Vec<MetricCounter> {
157        self.shared_snapshot()
158            .iter()
159            .map(|(name, value)| MetricCounter {
160                name: name.to_string(),
161                value,
162            })
163            .collect()
164    }
165
166    /// Returns an immutable sorted snapshot without cloning counter names.
167    ///
168    /// Retaining this view across an update makes that update clone the map
169    /// nodes. Each retained generation remains alive until its last snapshot
170    /// clone is dropped. Consumers must bound retained generations; registry
171    /// pressure reports the current generation, not all consumer-owned views.
172    pub fn shared_snapshot(&self) -> MetricSnapshot {
173        MetricSnapshot {
174            counters: Arc::clone(&self.state.lock().counters),
175        }
176    }
177
178    /// Returns current counter cardinality and retained-memory pressure.
179    pub fn pressure(&self) -> MetricRegistryPressure {
180        self.state.lock().pressure
181    }
182}
183
184impl Default for InMemoryMetrics {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190fn metric_name_is_valid(name: &str) -> bool {
191    !name.is_empty() && name.len() <= MAX_METRIC_NAME_BYTES && !name.contains('\0')
192}
193
194fn metric_retained_bytes(name: &str) -> usize {
195    METRIC_FIXED_BYTES.saturating_add(name.len())
196}
197
198#[cfg(test)]
199#[path = "metrics_tests.rs"]
200mod tests;