Skip to main content

oxideshield_guard/telemetry/
recorder.rs

1//! Metrics Recording
2//!
3//! Provides wrappers and utilities for recording guard metrics.
4
5use std::sync::Arc;
6use std::time::Instant;
7
8use super::metrics::{global_metrics, GuardMetricsCollector};
9use crate::guard::{Guard, GuardAction, GuardCheckResult};
10
11/// A guard wrapper that records metrics
12pub struct InstrumentedGuard<G: Guard> {
13    inner: G,
14    collector: Arc<GuardMetricsCollector>,
15    /// Guard type name for per-type metrics breakdown
16    guard_type: String,
17}
18
19impl<G: Guard> InstrumentedGuard<G> {
20    /// Create a new instrumented guard using the global collector
21    pub fn new(guard: G) -> Self {
22        Self::with_collector(guard, global_metrics())
23    }
24
25    /// Create a new instrumented guard with a specific collector
26    pub fn with_collector(guard: G, collector: Arc<GuardMetricsCollector>) -> Self {
27        Self {
28            guard_type: std::any::type_name::<G>().to_string(),
29            inner: guard,
30            collector,
31        }
32    }
33
34    /// Get a reference to the inner guard
35    pub fn inner(&self) -> &G {
36        &self.inner
37    }
38
39    /// Get the collector
40    pub fn collector(&self) -> Arc<GuardMetricsCollector> {
41        self.collector.clone()
42    }
43}
44
45impl<G: Guard> Guard for InstrumentedGuard<G> {
46    fn name(&self) -> &str {
47        self.inner.name()
48    }
49
50    fn action(&self) -> crate::guard::GuardAction {
51        self.inner.action()
52    }
53
54    fn check(&self, content: &str) -> GuardCheckResult {
55        self.collector.record_check_started();
56        let start = Instant::now();
57
58        let result = self.inner.check(content);
59
60        let elapsed = start.elapsed();
61        let sanitized = matches!(result.action, GuardAction::Sanitize);
62
63        // Record metrics with both guard name and type for per-type breakdown
64        self.collector.record_check_completed_with_type(
65            self.inner.name(),
66            &self.guard_type,
67            result.passed,
68            sanitized,
69            elapsed,
70        );
71
72        result
73    }
74}
75
76impl<G: Guard> InstrumentedGuard<G> {
77    /// Get the guard type name
78    pub fn guard_type(&self) -> &str {
79        &self.guard_type
80    }
81}
82
83/// Extension trait for adding instrumentation to guards
84pub trait InstrumentGuard: Guard + Sized {
85    /// Wrap this guard with metrics instrumentation
86    fn instrumented(self) -> InstrumentedGuard<Self> {
87        InstrumentedGuard::new(self)
88    }
89
90    /// Wrap this guard with a specific collector
91    fn instrumented_with(self, collector: Arc<GuardMetricsCollector>) -> InstrumentedGuard<Self> {
92        InstrumentedGuard::with_collector(self, collector)
93    }
94}
95
96// Implement for all guards
97impl<G: Guard + Sized> InstrumentGuard for G {}
98
99/// Metrics snapshot
100#[derive(Debug, Clone)]
101pub struct MetricsSnapshot {
102    /// Total checks
103    pub total_checks: u64,
104    /// Total blocks
105    pub total_blocks: u64,
106    /// Total passed
107    pub total_passed: u64,
108    /// Block rate
109    pub block_rate: f64,
110    /// Average latency
111    pub avg_latency_ms: f64,
112    /// P50 latency
113    pub p50_latency_ms: f64,
114    /// P99 latency
115    pub p99_latency_ms: f64,
116    /// Timestamp
117    pub timestamp: chrono::DateTime<chrono::Utc>,
118}
119
120impl MetricsSnapshot {
121    /// Take a snapshot from a collector
122    pub fn from_collector(collector: &GuardMetricsCollector) -> Self {
123        Self {
124            total_checks: collector.total_checks(),
125            total_blocks: collector.total_blocks(),
126            total_passed: collector.total_passed(),
127            block_rate: collector.block_rate(),
128            avg_latency_ms: collector.avg_latency_ms(),
129            p50_latency_ms: collector.p50_latency_ms(),
130            p99_latency_ms: collector.p99_latency_ms(),
131            timestamp: chrono::Utc::now(),
132        }
133    }
134
135    /// Take a snapshot from global metrics
136    pub fn global() -> Self {
137        Self::from_collector(&global_metrics())
138    }
139}
140
141/// Metrics reporter for periodic reporting
142pub struct MetricsReporter {
143    collector: Arc<GuardMetricsCollector>,
144    last_snapshot: parking_lot::RwLock<Option<MetricsSnapshot>>,
145}
146
147impl MetricsReporter {
148    /// Create a new reporter
149    pub fn new(collector: Arc<GuardMetricsCollector>) -> Self {
150        Self {
151            collector,
152            last_snapshot: parking_lot::RwLock::new(None),
153        }
154    }
155
156    /// Create a reporter using global metrics
157    pub fn global() -> Self {
158        Self::new(global_metrics())
159    }
160
161    /// Get current metrics
162    pub fn current(&self) -> MetricsSnapshot {
163        MetricsSnapshot::from_collector(&self.collector)
164    }
165
166    /// Get delta since last report
167    pub fn delta(&self) -> MetricsDelta {
168        let current = self.current();
169        let last = self.last_snapshot.read().clone();
170
171        let delta = if let Some(ref last) = last {
172            MetricsDelta {
173                checks: current.total_checks.saturating_sub(last.total_checks),
174                blocks: current.total_blocks.saturating_sub(last.total_blocks),
175                passed: current.total_passed.saturating_sub(last.total_passed),
176                duration: current.timestamp - last.timestamp,
177            }
178        } else {
179            MetricsDelta {
180                checks: current.total_checks,
181                blocks: current.total_blocks,
182                passed: current.total_passed,
183                duration: chrono::Duration::zero(),
184            }
185        };
186
187        *self.last_snapshot.write() = Some(current);
188        delta
189    }
190
191    /// Get throughput (checks per second) since last report
192    pub fn throughput(&self) -> f64 {
193        let delta = self.delta();
194        let seconds = delta.duration.num_milliseconds() as f64 / 1000.0;
195        if seconds > 0.0 {
196            delta.checks as f64 / seconds
197        } else {
198            0.0
199        }
200    }
201}
202
203/// Delta between metric snapshots
204#[derive(Debug, Clone)]
205pub struct MetricsDelta {
206    /// Checks since last snapshot
207    pub checks: u64,
208    /// Blocks since last snapshot
209    pub blocks: u64,
210    /// Passed since last snapshot
211    pub passed: u64,
212    /// Time since last snapshot
213    pub duration: chrono::Duration,
214}
215
216impl MetricsDelta {
217    /// Get block rate for this period
218    pub fn block_rate(&self) -> f64 {
219        if self.checks == 0 {
220            0.0
221        } else {
222            self.blocks as f64 / self.checks as f64
223        }
224    }
225
226    /// Get checks per second for this period
227    pub fn checks_per_second(&self) -> f64 {
228        let seconds = self.duration.num_milliseconds() as f64 / 1000.0;
229        if seconds > 0.0 {
230            self.checks as f64 / seconds
231        } else {
232            0.0
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::guard::LengthGuard;
241
242    #[test]
243    fn test_instrumented_guard() {
244        let collector = GuardMetricsCollector::shared();
245        let guard = LengthGuard::new("length")
246            .with_max_chars(100)
247            .instrumented_with(collector.clone());
248
249        let result = guard.check("short text");
250        assert!(result.passed);
251
252        assert_eq!(collector.total_checks(), 1);
253        assert_eq!(collector.total_passed(), 1);
254    }
255
256    #[test]
257    fn test_instrumented_guard_blocked() {
258        let collector = GuardMetricsCollector::shared();
259        let guard = LengthGuard::new("length")
260            .with_max_chars(5)
261            .instrumented_with(collector.clone());
262
263        let result = guard.check("this is too long");
264        assert!(!result.passed);
265
266        assert_eq!(collector.total_blocks(), 1);
267    }
268
269    #[test]
270    fn test_metrics_snapshot() {
271        let collector = GuardMetricsCollector::new();
272        collector.record_check_completed("test", true, false, std::time::Duration::from_millis(10));
273
274        let snapshot = MetricsSnapshot::from_collector(&collector);
275        assert_eq!(snapshot.total_checks, 1);
276        assert_eq!(snapshot.total_passed, 1);
277    }
278
279    #[test]
280    fn test_metrics_reporter() {
281        let collector = GuardMetricsCollector::shared();
282        let reporter = MetricsReporter::new(collector.clone());
283
284        // Initial delta
285        let delta = reporter.delta();
286        assert_eq!(delta.checks, 0);
287
288        // Add some checks
289        for _ in 0..5 {
290            collector.record_check_completed(
291                "test",
292                true,
293                false,
294                std::time::Duration::from_millis(10),
295            );
296        }
297
298        // Get new delta
299        let delta = reporter.delta();
300        assert_eq!(delta.checks, 5);
301    }
302
303    #[test]
304    fn test_instrument_trait() {
305        let guard = LengthGuard::new("length")
306            .with_max_chars(100)
307            .instrumented();
308
309        // Just verify it compiles and works
310        let result = guard.check("test");
311        assert!(result.passed);
312    }
313
314    #[test]
315    fn test_per_type_metrics() {
316        let collector = GuardMetricsCollector::shared();
317
318        // Record checks with type info
319        collector.record_check_completed_with_type(
320            "guard_a",
321            "oxideshield_guard::guards::length::LengthGuard",
322            true,
323            false,
324            std::time::Duration::from_millis(5),
325        );
326        collector.record_check_completed_with_type(
327            "guard_b",
328            "oxideshield_guard::guards::length::LengthGuard",
329            false,
330            false,
331            std::time::Duration::from_millis(10),
332        );
333        collector.record_check_completed_with_type(
334            "guard_c",
335            "oxideshield_guard::guards::pii::PIIGuard",
336            true,
337            false,
338            std::time::Duration::from_millis(15),
339        );
340
341        // Verify per-guard metrics
342        let per_guard = collector.per_guard_metrics();
343        assert!(per_guard.contains_key("guard_a"));
344        assert!(per_guard.contains_key("guard_b"));
345        assert!(per_guard.contains_key("guard_c"));
346
347        // Verify per-type metrics (aggregated by type)
348        let per_type = collector.per_type_metrics();
349        assert!(per_type.contains_key("oxideshield_guard::guards::length::LengthGuard"));
350        assert!(per_type.contains_key("oxideshield_guard::guards::pii::PIIGuard"));
351
352        // LengthGuard type should have 2 checks
353        let length_type = &per_type["oxideshield_guard::guards::length::LengthGuard"];
354        assert_eq!(length_type.checks, 2);
355        assert_eq!(length_type.passed, 1);
356        assert_eq!(length_type.blocks, 1);
357
358        // PIIGuard type should have 1 check
359        let pii_type = &per_type["oxideshield_guard::guards::pii::PIIGuard"];
360        assert_eq!(pii_type.checks, 1);
361        assert_eq!(pii_type.passed, 1);
362    }
363
364    #[test]
365    fn test_instrumented_guard_records_type() {
366        let collector = GuardMetricsCollector::shared();
367        collector.reset();
368
369        let guard = LengthGuard::new("typed_length")
370            .with_max_chars(100)
371            .instrumented_with(collector.clone());
372
373        // Verify guard type is captured
374        assert!(guard.guard_type().contains("LengthGuard"));
375
376        // Run a check
377        guard.check("test text");
378
379        // Verify per-type metrics recorded
380        let per_type = collector.per_type_metrics();
381        assert!(!per_type.is_empty(), "Should have per-type metrics");
382
383        // Find the LengthGuard type entry
384        let has_length_type = per_type.keys().any(|k| k.contains("LengthGuard"));
385        assert!(has_length_type, "Should have LengthGuard type metrics");
386    }
387}