Skip to main content

appcore_ops/
observation.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: observation.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/21 23:21:21 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Bounded, tool-independent runtime observation events.
12
13use appcore_core::redact_text_with_limit;
14use parking_lot::{Mutex, RwLock};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, VecDeque};
17use std::sync::Arc;
18
19/// Maximum retained attributes per observation.
20pub const MAX_OBSERVATION_ATTRIBUTES: usize = 32;
21/// Maximum UTF-8 bytes retained in an observation name.
22pub const MAX_OBSERVATION_NAME_BYTES: usize = 128;
23/// Maximum UTF-8 bytes retained in an observation attribute key.
24pub const MAX_OBSERVATION_KEY_BYTES: usize = 64;
25/// Maximum UTF-8 bytes retained in an observation attribute value.
26pub const MAX_OBSERVATION_VALUE_BYTES: usize = 1_024;
27/// Maximum UTF-8 bytes retained in a trace identifier.
28pub const MAX_OBSERVATION_TRACE_BYTES: usize = 256;
29
30/// Runtime subsystem that produced an observation.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ObservationKind {
34    /// Process lifecycle.
35    Lifecycle,
36    /// Runtime or deployment configuration.
37    Configuration,
38    /// Health evaluation.
39    Health,
40    /// Authentication, authorization or secret boundary.
41    Security,
42    /// Storage operation.
43    Storage,
44    /// Control-plane operation.
45    ControlPlane,
46    /// Direct peer RPC operation.
47    PeerRpc,
48    /// Scheduler operation.
49    Scheduler,
50    /// Synchronization operation.
51    Sync,
52    /// Audit operation.
53    Audit,
54    /// Diagnostic operation.
55    Diagnostic,
56}
57
58/// Severity of one runtime observation.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum ObservationSeverity {
62    /// Developer diagnostic fact.
63    Debug,
64    /// Normal operational fact.
65    Info,
66    /// Recoverable or degraded condition.
67    Warning,
68    /// Failed operation.
69    Error,
70}
71
72/// Generic runtime fact suitable for logs, diagnostics, metrics and audit sinks.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ObservationEvent {
75    /// Runtime subsystem category.
76    pub kind: ObservationKind,
77    /// Observation severity.
78    pub severity: ObservationSeverity,
79    /// Stable observation name.
80    pub name: String,
81    /// Timestamp in Unix milliseconds.
82    pub timestamp_ms: u64,
83    /// Optional trace identity.
84    pub trace_id: Option<String>,
85    /// Bounded non-sensitive dimensions.
86    pub attributes: BTreeMap<String, String>,
87}
88
89impl ObservationEvent {
90    /// Creates an event without attributes.
91    pub fn new(
92        kind: ObservationKind,
93        severity: ObservationSeverity,
94        name: impl Into<String>,
95        timestamp_ms: u64,
96    ) -> Self {
97        Self {
98            kind,
99            severity,
100            name: name.into(),
101            timestamp_ms,
102            trace_id: None,
103            attributes: BTreeMap::new(),
104        }
105    }
106
107    /// Attaches a trace identity.
108    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
109        self.trace_id = Some(trace_id.into());
110        self
111    }
112
113    /// Adds one attribute. Sensitive keys and values are redacted immediately.
114    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
115        let key = redact_text_with_limit(&key.into(), MAX_OBSERVATION_KEY_BYTES);
116        if self.attributes.len() >= MAX_OBSERVATION_ATTRIBUTES
117            && !self.attributes.contains_key(&key)
118        {
119            return self;
120        }
121        let value = if is_sensitive_key(&key) {
122            "[REDACTED]".to_string()
123        } else {
124            redact_text_with_limit(&value.into(), MAX_OBSERVATION_VALUE_BYTES)
125        };
126        self.attributes.insert(key, value);
127        self
128    }
129
130    pub(crate) fn redacted(mut self) -> Self {
131        self.name = redact_text_with_limit(&self.name, MAX_OBSERVATION_NAME_BYTES);
132        self.trace_id = self
133            .trace_id
134            .map(|value| redact_text_with_limit(&value, MAX_OBSERVATION_TRACE_BYTES));
135        while self.attributes.len() > MAX_OBSERVATION_ATTRIBUTES {
136            let Some(key) = self.attributes.keys().next_back().cloned() else {
137                break;
138            };
139            self.attributes.remove(&key);
140        }
141        for (key, value) in &mut self.attributes {
142            *value = if is_sensitive_key(key) {
143                "[REDACTED]".to_string()
144            } else {
145                redact_text_with_limit(value, MAX_OBSERVATION_VALUE_BYTES)
146            };
147        }
148        self
149    }
150}
151
152/// Destination for generic observation events.
153pub trait ObservationSink: Send + Sync {
154    /// Emits one event without blocking on external tooling.
155    fn emit(&self, event: ObservationEvent);
156}
157
158/// Bounded in-memory sink used by diagnostics and embedded runtimes.
159#[derive(Clone)]
160pub struct InMemoryObservationSink {
161    capacity: usize,
162    events: Arc<Mutex<VecDeque<ObservationEvent>>>,
163    drains: Arc<RwLock<Vec<Arc<dyn ObservationSink>>>>,
164}
165
166impl std::fmt::Debug for InMemoryObservationSink {
167    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        formatter
169            .debug_struct("InMemoryObservationSink")
170            .field("capacity", &self.capacity)
171            .field("event_count", &self.len())
172            .field("drain_count", &self.drains.read().len())
173            .finish()
174    }
175}
176
177impl InMemoryObservationSink {
178    /// Creates a sink that retains the newest `capacity` events.
179    pub fn new(capacity: usize) -> Self {
180        Self {
181            capacity: capacity.max(1),
182            events: Arc::new(Mutex::new(VecDeque::with_capacity(capacity.max(1)))),
183            drains: Arc::new(RwLock::new(Vec::new())),
184        }
185    }
186
187    /// Adds an operational drain that receives future redacted events.
188    pub fn add_drain(&self, drain: Arc<dyn ObservationSink>) {
189        self.drains.write().push(drain);
190    }
191
192    /// Returns the number of attached operational drains.
193    pub fn drain_count(&self) -> usize {
194        self.drains.read().len()
195    }
196
197    /// Returns a stable snapshot from oldest to newest.
198    pub fn snapshot(&self) -> Vec<ObservationEvent> {
199        self.events.lock().iter().cloned().collect()
200    }
201
202    /// Returns the number of retained events.
203    pub fn len(&self) -> usize {
204        self.events.lock().len()
205    }
206
207    /// Reports whether no events are retained.
208    pub fn is_empty(&self) -> bool {
209        self.events.lock().is_empty()
210    }
211}
212
213impl Default for InMemoryObservationSink {
214    fn default() -> Self {
215        Self::new(1_024)
216    }
217}
218
219impl ObservationSink for InMemoryObservationSink {
220    fn emit(&self, event: ObservationEvent) {
221        let event = event.redacted();
222        let mut events = self.events.lock();
223        if events.len() == self.capacity {
224            let _ = events.pop_front();
225        }
226        events.push_back(event.clone());
227        drop(events);
228        let drains = self.drains.read().clone();
229        for drain in drains {
230            drain.emit(event.clone());
231        }
232    }
233}
234
235fn is_sensitive_key(key: &str) -> bool {
236    let normalized = key.to_ascii_lowercase();
237    ["secret", "password", "token", "credential", "private_key"]
238        .iter()
239        .any(|fragment| normalized.contains(fragment))
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn bounded_sink_discards_oldest_event() {
248        let sink = InMemoryObservationSink::new(2);
249        for index in 0..3 {
250            sink.emit(ObservationEvent::new(
251                ObservationKind::Lifecycle,
252                ObservationSeverity::Info,
253                format!("runtime.event.{index}"),
254                index,
255            ));
256        }
257        let snapshot = sink.snapshot();
258        assert_eq!(snapshot.len(), 2);
259        assert_eq!(snapshot[0].name, "runtime.event.1");
260    }
261
262    #[test]
263    fn sink_redacts_sensitive_attributes() {
264        let sink = InMemoryObservationSink::new(2);
265        sink.emit(
266            ObservationEvent::new(
267                ObservationKind::Security,
268                ObservationSeverity::Warning,
269                "security.rejected",
270                1,
271            )
272            .with_attribute("access_token", "raw-secret"),
273        );
274        assert_eq!(sink.snapshot()[0].attributes["access_token"], "[REDACTED]");
275    }
276
277    #[test]
278    fn sink_bounds_names_values_and_attribute_count() {
279        let mut event = ObservationEvent::new(
280            ObservationKind::Diagnostic,
281            ObservationSeverity::Info,
282            "n".repeat(1_000),
283            1,
284        );
285        for index in 0..100 {
286            event = event.with_attribute(format!("key-{index}"), "v".repeat(2_000));
287        }
288        let sink = InMemoryObservationSink::new(1);
289        sink.emit(event);
290        let event = &sink.snapshot()[0];
291
292        assert!(event.name.len() <= MAX_OBSERVATION_NAME_BYTES);
293        assert_eq!(event.attributes.len(), MAX_OBSERVATION_ATTRIBUTES);
294        assert!(event
295            .attributes
296            .values()
297            .all(|value| value.len() <= MAX_OBSERVATION_VALUE_BYTES));
298    }
299}