use appcore_core::redact_text_with_limit;
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, VecDeque};
use std::sync::Arc;
pub const MAX_OBSERVATION_ATTRIBUTES: usize = 32;
pub const MAX_OBSERVATION_NAME_BYTES: usize = 128;
pub const MAX_OBSERVATION_KEY_BYTES: usize = 64;
pub const MAX_OBSERVATION_VALUE_BYTES: usize = 1_024;
pub const MAX_OBSERVATION_TRACE_BYTES: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ObservationKind {
Lifecycle,
Configuration,
Health,
Security,
Storage,
ControlPlane,
PeerRpc,
Scheduler,
Sync,
Audit,
Diagnostic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ObservationSeverity {
Debug,
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservationEvent {
pub kind: ObservationKind,
pub severity: ObservationSeverity,
pub name: String,
pub timestamp_ms: u64,
pub trace_id: Option<String>,
pub attributes: BTreeMap<String, String>,
}
impl ObservationEvent {
pub fn new(
kind: ObservationKind,
severity: ObservationSeverity,
name: impl Into<String>,
timestamp_ms: u64,
) -> Self {
Self {
kind,
severity,
name: name.into(),
timestamp_ms,
trace_id: None,
attributes: BTreeMap::new(),
}
}
pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
self.trace_id = Some(trace_id.into());
self
}
pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let key = redact_text_with_limit(&key.into(), MAX_OBSERVATION_KEY_BYTES);
if self.attributes.len() >= MAX_OBSERVATION_ATTRIBUTES
&& !self.attributes.contains_key(&key)
{
return self;
}
let value = if is_sensitive_key(&key) {
"[REDACTED]".to_string()
} else {
redact_text_with_limit(&value.into(), MAX_OBSERVATION_VALUE_BYTES)
};
self.attributes.insert(key, value);
self
}
pub(crate) fn redacted(mut self) -> Self {
self.name = redact_text_with_limit(&self.name, MAX_OBSERVATION_NAME_BYTES);
self.trace_id = self
.trace_id
.map(|value| redact_text_with_limit(&value, MAX_OBSERVATION_TRACE_BYTES));
while self.attributes.len() > MAX_OBSERVATION_ATTRIBUTES {
let Some(key) = self.attributes.keys().next_back().cloned() else {
break;
};
self.attributes.remove(&key);
}
for (key, value) in &mut self.attributes {
*value = if is_sensitive_key(key) {
"[REDACTED]".to_string()
} else {
redact_text_with_limit(value, MAX_OBSERVATION_VALUE_BYTES)
};
}
self
}
}
pub trait ObservationSink: Send + Sync {
fn emit(&self, event: ObservationEvent);
}
#[derive(Clone)]
pub struct InMemoryObservationSink {
capacity: usize,
events: Arc<Mutex<VecDeque<ObservationEvent>>>,
drains: Arc<RwLock<Vec<Arc<dyn ObservationSink>>>>,
}
impl std::fmt::Debug for InMemoryObservationSink {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("InMemoryObservationSink")
.field("capacity", &self.capacity)
.field("event_count", &self.len())
.field("drain_count", &self.drains.read().len())
.finish()
}
}
impl InMemoryObservationSink {
pub fn new(capacity: usize) -> Self {
Self {
capacity: capacity.max(1),
events: Arc::new(Mutex::new(VecDeque::with_capacity(capacity.max(1)))),
drains: Arc::new(RwLock::new(Vec::new())),
}
}
pub fn add_drain(&self, drain: Arc<dyn ObservationSink>) {
self.drains.write().push(drain);
}
pub fn drain_count(&self) -> usize {
self.drains.read().len()
}
pub fn snapshot(&self) -> Vec<ObservationEvent> {
self.events.lock().iter().cloned().collect()
}
pub fn len(&self) -> usize {
self.events.lock().len()
}
pub fn is_empty(&self) -> bool {
self.events.lock().is_empty()
}
}
impl Default for InMemoryObservationSink {
fn default() -> Self {
Self::new(1_024)
}
}
impl ObservationSink for InMemoryObservationSink {
fn emit(&self, event: ObservationEvent) {
let event = event.redacted();
let mut events = self.events.lock();
if events.len() == self.capacity {
let _ = events.pop_front();
}
events.push_back(event.clone());
drop(events);
let drains = self.drains.read().clone();
for drain in drains {
drain.emit(event.clone());
}
}
}
fn is_sensitive_key(key: &str) -> bool {
let normalized = key.to_ascii_lowercase();
["secret", "password", "token", "credential", "private_key"]
.iter()
.any(|fragment| normalized.contains(fragment))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bounded_sink_discards_oldest_event() {
let sink = InMemoryObservationSink::new(2);
for index in 0..3 {
sink.emit(ObservationEvent::new(
ObservationKind::Lifecycle,
ObservationSeverity::Info,
format!("runtime.event.{index}"),
index,
));
}
let snapshot = sink.snapshot();
assert_eq!(snapshot.len(), 2);
assert_eq!(snapshot[0].name, "runtime.event.1");
}
#[test]
fn sink_redacts_sensitive_attributes() {
let sink = InMemoryObservationSink::new(2);
sink.emit(
ObservationEvent::new(
ObservationKind::Security,
ObservationSeverity::Warning,
"security.rejected",
1,
)
.with_attribute("access_token", "raw-secret"),
);
assert_eq!(sink.snapshot()[0].attributes["access_token"], "[REDACTED]");
}
#[test]
fn sink_bounds_names_values_and_attribute_count() {
let mut event = ObservationEvent::new(
ObservationKind::Diagnostic,
ObservationSeverity::Info,
"n".repeat(1_000),
1,
);
for index in 0..100 {
event = event.with_attribute(format!("key-{index}"), "v".repeat(2_000));
}
let sink = InMemoryObservationSink::new(1);
sink.emit(event);
let event = &sink.snapshot()[0];
assert!(event.name.len() <= MAX_OBSERVATION_NAME_BYTES);
assert_eq!(event.attributes.len(), MAX_OBSERVATION_ATTRIBUTES);
assert!(event
.attributes
.values()
.all(|value| value.len() <= MAX_OBSERVATION_VALUE_BYTES));
}
}