use std::collections::VecDeque;
use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime};
use parking_lot::RwLock;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct RequestId(String);
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
impl RequestId {
pub fn generate() -> Self {
let seq = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
Self(format!("req-{seq:016x}"))
}
pub fn from_header(value: &str) -> Option<Self> {
if value.is_empty() || value.len() > 128 {
return None;
}
Some(Self(value.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RequestId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheMutation {
Insert,
Evict,
Clear,
Warmup,
Invalidate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MutationEvictionReason {
Ttl,
Capacity,
Manual,
VersionDrift,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ClearScope {
All,
Context(String),
Pattern(String),
}
#[derive(Debug, Clone, Serialize)]
pub struct MutationAuditEntry {
pub timestamp_ms: u64,
pub mutation: CacheMutation,
pub key: String,
pub context: Option<String>,
pub eviction_reason: Option<MutationEvictionReason>,
pub clear_scope: Option<ClearScope>,
pub request_id: Option<String>,
pub entries_affected: u32,
}
impl MutationAuditEntry {
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub fn insert(
key: impl Into<String>,
context: Option<String>,
request_id: Option<&RequestId>,
) -> Self {
Self {
timestamp_ms: Self::now_ms(),
mutation: CacheMutation::Insert,
key: key.into(),
context,
eviction_reason: None,
clear_scope: None,
request_id: request_id.map(|r| r.as_str().to_string()),
entries_affected: 1,
}
}
pub fn evict(key: impl Into<String>, reason: MutationEvictionReason) -> Self {
Self {
timestamp_ms: Self::now_ms(),
mutation: CacheMutation::Evict,
key: key.into(),
context: None,
eviction_reason: Some(reason),
clear_scope: None,
request_id: None,
entries_affected: 1,
}
}
pub fn clear(scope: ClearScope, entries_affected: u32) -> Self {
Self {
timestamp_ms: Self::now_ms(),
mutation: CacheMutation::Clear,
key: String::new(),
context: None,
eviction_reason: None,
clear_scope: Some(scope),
request_id: None,
entries_affected,
}
}
}
pub struct CacheMutationLog {
entries: RwLock<VecDeque<MutationAuditEntry>>,
capacity: usize,
total_logged: AtomicU64,
}
impl CacheMutationLog {
pub fn new(capacity: usize) -> Self {
Self {
entries: RwLock::new(VecDeque::with_capacity(capacity.min(10_000))),
capacity,
total_logged: AtomicU64::new(0),
}
}
pub fn record(&self, entry: MutationAuditEntry) {
self.total_logged.fetch_add(1, Ordering::Relaxed);
let mut entries = self.entries.write();
if entries.len() >= self.capacity {
entries.pop_front();
}
entries.push_back(entry);
}
pub fn recent(&self, limit: usize) -> Vec<MutationAuditEntry> {
let entries = self.entries.read();
entries.iter().rev().take(limit).cloned().collect()
}
pub fn total_logged(&self) -> u64 {
self.total_logged.load(Ordering::Relaxed)
}
pub fn len(&self) -> usize {
self.entries.read().len()
}
pub fn is_empty(&self) -> bool {
self.entries.read().is_empty()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TraceStage {
pub name: String,
pub duration_us: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct RequestTrace {
pub request_id: String,
pub total_duration_us: u64,
pub stages: Vec<TraceStage>,
pub cache_hit: bool,
pub upstream_name: Option<String>,
}
pub struct TraceBuilder {
request_id: RequestId,
start: Instant,
stages: Vec<(String, Instant, Option<Duration>)>,
cache_hit: bool,
upstream_name: Option<String>,
}
impl TraceBuilder {
pub fn new(request_id: RequestId) -> Self {
Self {
request_id,
start: Instant::now(),
stages: Vec::new(),
cache_hit: false,
upstream_name: None,
}
}
pub fn begin_stage(&mut self, name: impl Into<String>) -> usize {
let idx = self.stages.len();
self.stages.push((name.into(), Instant::now(), None));
idx
}
pub fn end_stage(&mut self, idx: usize) {
if let Some(stage) = self.stages.get_mut(idx) {
stage.2 = Some(stage.1.elapsed());
}
}
pub fn set_cache_hit(&mut self, hit: bool) {
self.cache_hit = hit;
}
pub fn set_upstream(&mut self, name: impl Into<String>) {
self.upstream_name = Some(name.into());
}
pub fn finish(self) -> RequestTrace {
let total = self.start.elapsed();
let stages = self
.stages
.into_iter()
.map(|(name, start, dur)| TraceStage {
name,
duration_us: dur.unwrap_or_else(|| start.elapsed()).as_micros() as u64,
})
.collect();
RequestTrace {
request_id: self.request_id.to_string(),
total_duration_us: total.as_micros() as u64,
stages,
cache_hit: self.cache_hit,
upstream_name: self.upstream_name,
}
}
}
#[cfg(test)]
#[path = "tests/observability_tests.rs"]
mod tests;