osproxy_observe/breakglass.rs
1//! Break-glass ring buffer: a bounded, in-order tape of recent explanations
2//! captured **only when a directive asks** (`ring_buffer: true`, `docs/05` ยง5).
3//!
4//! Distinct from [`ExplainStore`](crate::ExplainStore), which is the always-on,
5//! lookup-by-request-id store behind `/debug/explain/{id}`. The break-glass
6//! buffer is a *sequence* an operator turns on deliberately, when a class of
7//! request is failing and the ids aren't known up front, flip a `ring_buffer`
8//! directive and read back the last N matching requests as a forensic tape.
9//!
10//! Single-instance by design (the captured tape lives on the instance that
11//! handled the requests); bounded so it costs nothing until used and cannot grow
12//! without limit once on. Shape-only, inherited from the explain document it
13//! stores, it cannot reveal a tenant value because none was ever captured.
14
15use std::collections::VecDeque;
16use std::sync::{Mutex, PoisonError};
17
18use serde_json::Value;
19
20/// A bounded in-memory ring of recent explanation documents, captured on demand.
21#[derive(Debug)]
22pub struct BreakGlassBuffer {
23 capacity: usize,
24 entries: Mutex<VecDeque<Value>>,
25}
26
27impl BreakGlassBuffer {
28 /// Creates a buffer holding at most `capacity` recent captures.
29 #[must_use]
30 pub fn new(capacity: usize) -> Self {
31 Self {
32 capacity: capacity.max(1),
33 entries: Mutex::new(VecDeque::new()),
34 }
35 }
36
37 /// Captures one explanation document, evicting the oldest if full.
38 pub fn capture(&self, doc: Value) {
39 let mut entries = self.lock();
40 if entries.len() >= self.capacity {
41 entries.pop_front();
42 }
43 entries.push_back(doc);
44 }
45
46 /// A snapshot of the captured tape, oldest first, the break-glass read.
47 #[must_use]
48 pub fn snapshot(&self) -> Vec<Value> {
49 self.lock().iter().cloned().collect()
50 }
51
52 /// How many captures the tape currently holds.
53 #[must_use]
54 pub fn len(&self) -> usize {
55 self.lock().len()
56 }
57
58 /// Whether the tape is empty (nothing captured yet).
59 #[must_use]
60 pub fn is_empty(&self) -> bool {
61 self.lock().is_empty()
62 }
63
64 /// Locks the tape, recovering a poisoned lock, it is append-only forensic
65 /// data with no invariant a panicking holder could tear (NFR-R1).
66 fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque<Value>> {
67 self.entries.lock().unwrap_or_else(PoisonError::into_inner)
68 }
69}
70
71#[cfg(test)]
72#[path = "breakglass_tests.rs"]
73mod tests;