1use 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
19pub const MAX_OBSERVATION_ATTRIBUTES: usize = 32;
21pub const MAX_OBSERVATION_NAME_BYTES: usize = 128;
23pub const MAX_OBSERVATION_KEY_BYTES: usize = 64;
25pub const MAX_OBSERVATION_VALUE_BYTES: usize = 1_024;
27pub const MAX_OBSERVATION_TRACE_BYTES: usize = 256;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ObservationKind {
34 Lifecycle,
36 Configuration,
38 Health,
40 Security,
42 Storage,
44 ControlPlane,
46 PeerRpc,
48 Scheduler,
50 Sync,
52 Audit,
54 Diagnostic,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum ObservationSeverity {
62 Debug,
64 Info,
66 Warning,
68 Error,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ObservationEvent {
75 pub kind: ObservationKind,
77 pub severity: ObservationSeverity,
79 pub name: String,
81 pub timestamp_ms: u64,
83 pub trace_id: Option<String>,
85 pub attributes: BTreeMap<String, String>,
87}
88
89impl ObservationEvent {
90 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 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 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
152pub trait ObservationSink: Send + Sync {
154 fn emit(&self, event: ObservationEvent);
156}
157
158#[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 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 pub fn add_drain(&self, drain: Arc<dyn ObservationSink>) {
189 self.drains.write().push(drain);
190 }
191
192 pub fn drain_count(&self) -> usize {
194 self.drains.read().len()
195 }
196
197 pub fn snapshot(&self) -> Vec<ObservationEvent> {
199 self.events.lock().iter().cloned().collect()
200 }
201
202 pub fn len(&self) -> usize {
204 self.events.lock().len()
205 }
206
207 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}