1use parking_lot::RwLock;
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::Arc;
10use std::time::Duration;
11
12pub const DEFAULT_LABEL_CARDINALITY_CAP: usize = 4096;
18
19fn warn_once(flag: &AtomicBool, msg: String) {
20 if !flag.swap(true, Ordering::Relaxed) {
21 tracing::warn!("{}", msg);
22 }
23}
24
25#[derive(Debug, Default)]
27pub struct Counter {
28 inner: AtomicU64,
29}
30
31impl Counter {
32 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub fn inc(&self) -> u64 {
38 self.inner.fetch_add(1, Ordering::Relaxed) + 1
39 }
40
41 pub fn add(&self, n: u64) -> u64 {
43 self.inner.fetch_add(n, Ordering::Relaxed) + n
44 }
45
46 pub fn get(&self) -> u64 {
47 self.inner.load(Ordering::Relaxed)
48 }
49}
50
51#[derive(Debug, Default)]
53pub struct Gauge {
54 inner: AtomicU64,
55}
56
57impl Gauge {
58 pub fn new() -> Self {
59 Self::default()
60 }
61
62 pub fn set(&self, v: f64) {
63 self.inner.store(v.to_bits(), Ordering::Relaxed);
64 }
65
66 pub fn get(&self) -> f64 {
67 f64::from_bits(self.inner.load(Ordering::Relaxed))
68 }
69}
70
71#[derive(Debug)]
75pub struct Histogram {
76 name: String,
77 buckets: Vec<f64>,
78 bucket_counts: Vec<Counter>,
80 sum_micros: AtomicU64,
81 count: Counter,
82}
83
84impl Histogram {
85 pub fn new(name: impl Into<String>, buckets: &[f64]) -> Self {
89 let mut sorted: Vec<_> = buckets
90 .iter()
91 .copied()
92 .filter(|bucket| bucket.is_finite())
93 .collect();
94 sorted.sort_by(f64::total_cmp);
95 sorted.dedup_by(|left, right| *left == *right);
96 let bucket_counts = (0..sorted.len() + 1).map(|_| Counter::new()).collect();
97 Self {
98 name: name.into(),
99 buckets: sorted,
100 bucket_counts,
101 sum_micros: AtomicU64::new(0),
102 count: Counter::new(),
103 }
104 }
105
106 pub fn observe(&self, d: Duration) {
107 let secs = d.as_secs_f64();
108 self.count.inc();
109 self.sum_micros
110 .fetch_add(d.as_micros() as u64, Ordering::Relaxed);
111 for (i, b) in self.buckets.iter().enumerate() {
112 if secs <= *b {
113 self.bucket_counts[i].inc();
114 }
115 }
116 self.bucket_counts[self.buckets.len()].inc();
118 }
119
120 pub fn count(&self) -> u64 {
121 self.count.get()
122 }
123
124 pub fn sum_seconds(&self) -> f64 {
125 self.sum_micros.load(Ordering::Relaxed) as f64 / 1_000_000.0
126 }
127
128 pub fn bucket_counts(&self) -> Vec<(f64, u64)> {
129 self.buckets
130 .iter()
131 .zip(self.bucket_counts.iter())
132 .map(|(b, c)| (*b, c.get()))
133 .chain(std::iter::once((
134 f64::INFINITY,
135 self.bucket_counts.last().unwrap().get(),
136 )))
137 .collect()
138 }
139
140 pub fn name(&self) -> &str {
141 &self.name
142 }
143}
144
145pub struct Metrics {
147 decision_total: RwLock<HashMap<String, Arc<Counter>>>,
149 decision_duration: RwLock<HashMap<String, Arc<Histogram>>>,
151 delegation_mint_total: Counter,
153 delegation_verify_total: RwLock<HashMap<String, Arc<Counter>>>,
155 cache_hit_total: Counter,
157 cache_miss_total: Counter,
158 policy_reload_total: Counter,
160 pdp_error_total: RwLock<HashMap<String, Arc<Counter>>>,
162 cardinality_cap: usize,
165 warn_decision_cap: AtomicBool,
168 warn_duration_cap: AtomicBool,
169 warn_pdp_error_cap: AtomicBool,
170}
171
172impl Default for Metrics {
173 fn default() -> Self {
174 Self::with_cap(DEFAULT_LABEL_CARDINALITY_CAP)
175 }
176}
177
178impl Metrics {
179 pub fn new() -> Self {
183 Self::default()
184 }
185
186 pub fn with_cap(cardinality_cap: usize) -> Self {
189 Self {
190 decision_total: RwLock::new(HashMap::new()),
191 decision_duration: RwLock::new(HashMap::new()),
192 delegation_mint_total: Counter::new(),
193 delegation_verify_total: RwLock::new(HashMap::new()),
194 cache_hit_total: Counter::new(),
195 cache_miss_total: Counter::new(),
196 policy_reload_total: Counter::new(),
197 pdp_error_total: RwLock::new(HashMap::new()),
198 cardinality_cap,
199 warn_decision_cap: AtomicBool::new(false),
200 warn_duration_cap: AtomicBool::new(false),
201 warn_pdp_error_cap: AtomicBool::new(false),
202 }
203 }
204
205 pub fn cardinality_cap(&self) -> usize {
206 self.cardinality_cap
207 }
208
209 fn label_key(parts: &[&str]) -> String {
210 parts.join("\x1f")
211 }
212
213 pub fn record_decision(
214 &self,
215 effect: &str,
216 policy_id: &str,
217 action: &str,
218 tenant_id: &str,
219 duration: Duration,
220 ) {
221 let key = Self::label_key(&[effect, policy_id, action, tenant_id]);
222 {
223 let mut totals = self.decision_total.write();
224 if let Some(c) = totals.get(&key) {
225 c.inc();
226 } else if totals.len() < self.cardinality_cap {
227 let c = Arc::new(Counter::new());
228 c.inc();
229 totals.insert(key.clone(), c);
230 } else {
231 warn_once(
232 &self.warn_decision_cap,
233 format!(
234 "agentguard.decision.total reached cardinality cap {}; \
235 dropping new label-tuples",
236 self.cardinality_cap
237 ),
238 );
239 return;
240 }
241 }
242 let dur_key = Self::label_key(&[action, tenant_id]);
243 let mut durations = self.decision_duration.write();
244 if let Some(h) = durations.get(&dur_key) {
245 h.observe(duration);
246 } else if durations.len() < self.cardinality_cap {
247 let h = Arc::new(Histogram::new(
248 "agentguard.decision.duration_seconds",
249 &[0.001, 0.01, 0.1, 1.0, 10.0],
250 ));
251 h.observe(duration);
252 durations.insert(dur_key, h);
253 } else {
254 warn_once(
255 &self.warn_duration_cap,
256 format!(
257 "agentguard.decision.duration_seconds reached cardinality cap {}; \
258 dropping new label-tuples",
259 self.cardinality_cap
260 ),
261 );
262 }
263 }
264
265 pub fn record_delegation_mint(&self) {
266 self.delegation_mint_total.inc();
267 }
268
269 pub fn record_delegation_verify(&self, success: bool) {
270 let key = if success { "success" } else { "failure" };
271 self.delegation_verify_total
272 .write()
273 .entry(key.to_string())
274 .or_insert_with(|| Arc::new(Counter::new()))
275 .inc();
276 }
277
278 pub fn record_cache_hit(&self) {
279 self.cache_hit_total.inc();
280 }
281
282 pub fn record_cache_miss(&self) {
283 self.cache_miss_total.inc();
284 }
285
286 pub fn record_policy_reload(&self) {
287 self.policy_reload_total.inc();
288 }
289
290 pub fn record_pdp_error(&self, fallback: &str) {
291 let key = fallback.to_string();
292 let mut pdp_errors = self.pdp_error_total.write();
293 if let Some(c) = pdp_errors.get(&key) {
294 c.inc();
295 } else if pdp_errors.len() < self.cardinality_cap {
296 let c = Arc::new(Counter::new());
297 c.inc();
298 pdp_errors.insert(key, c);
299 } else {
300 warn_once(
301 &self.warn_pdp_error_cap,
302 format!(
303 "agentguard.pdp.error.total reached cardinality cap {}; \
304 dropping new label-tuples",
305 self.cardinality_cap
306 ),
307 );
308 }
309 }
310
311 pub fn render_prometheus(&self) -> String {
317 use std::fmt::Write as _;
318
319 let mut out = String::with_capacity(4096);
322
323 writeln!(
324 out,
325 "# HELP agentguard_decision_total Total authorization decisions"
326 )
327 .unwrap();
328 writeln!(out, "# TYPE agentguard_decision_total counter").unwrap();
329 let totals = self.decision_total.read();
330 for (key, c) in totals.iter() {
331 let parts: Vec<&str> = key.split('\x1f').collect();
332 let effect = parts.first().copied().unwrap_or("");
333 let policy_id = parts.get(1).copied().unwrap_or("");
334 let action = parts.get(2).copied().unwrap_or("");
335 let tenant_id = parts.get(3).copied().unwrap_or("");
336 writeln!(
337 out,
338 "agentguard_decision_total{{effect=\"{}\",policy_id=\"{}\",action=\"{}\",tenant_id=\"{}\"}} {}",
339 escape_label(effect),
340 escape_label(policy_id),
341 escape_label(action),
342 escape_label(tenant_id),
343 c.get(),
344 )
345 .unwrap();
346 }
347 drop(totals);
348
349 writeln!(
350 out,
351 "# HELP agentguard_decision_duration_seconds Decision evaluation time"
352 )
353 .unwrap();
354 writeln!(out, "# TYPE agentguard_decision_duration_seconds histogram").unwrap();
355 let durations = self.decision_duration.read();
356 for (key, h) in durations.iter() {
357 let parts: Vec<&str> = key.split('\x1f').collect();
358 let action = parts.first().copied().unwrap_or("");
359 let tenant_id = parts.get(1).copied().unwrap_or("");
360 for (b, count) in h.bucket_counts() {
361 let le = if b.is_infinite() {
362 "+Inf".to_string()
363 } else {
364 b.to_string()
365 };
366 writeln!(
367 out,
368 "agentguard_decision_duration_seconds_bucket{{action=\"{}\",tenant_id=\"{}\",le=\"{}\"}} {}",
369 escape_label(action),
370 escape_label(tenant_id),
371 le,
372 count,
373 )
374 .unwrap();
375 }
376 writeln!(
377 out,
378 "agentguard_decision_duration_seconds_count{{action=\"{}\",tenant_id=\"{}\"}} {}",
379 escape_label(action),
380 escape_label(tenant_id),
381 h.count(),
382 )
383 .unwrap();
384 writeln!(
385 out,
386 "agentguard_decision_duration_seconds_sum{{action=\"{}\",tenant_id=\"{}\"}} {}",
387 escape_label(action),
388 escape_label(tenant_id),
389 h.sum_seconds(),
390 )
391 .unwrap();
392 }
393 drop(durations);
394
395 writeln!(
396 out,
397 "# HELP agentguard_delegation_mint_total Total delegation tokens minted"
398 )
399 .unwrap();
400 writeln!(out, "# TYPE agentguard_delegation_mint_total counter").unwrap();
401 writeln!(
402 out,
403 "agentguard_delegation_mint_total {}",
404 self.delegation_mint_total.get(),
405 )
406 .unwrap();
407
408 writeln!(
409 out,
410 "# HELP agentguard_delegation_verify_total Total delegation verifications"
411 )
412 .unwrap();
413 writeln!(out, "# TYPE agentguard_delegation_verify_total counter").unwrap();
414 let verify = self.delegation_verify_total.read();
415 for (outcome, c) in verify.iter() {
416 writeln!(
417 out,
418 "agentguard_delegation_verify_total{{outcome=\"{}\"}} {}",
419 escape_label(outcome),
420 c.get(),
421 )
422 .unwrap();
423 }
424 drop(verify);
425
426 writeln!(out, "# HELP agentguard_cache_hit_total Decision cache hits").unwrap();
427 writeln!(out, "# TYPE agentguard_cache_hit_total counter").unwrap();
428 writeln!(
429 out,
430 "agentguard_cache_hit_total {}",
431 self.cache_hit_total.get()
432 )
433 .unwrap();
434 writeln!(out, "# TYPE agentguard_cache_miss_total counter").unwrap();
435 writeln!(
436 out,
437 "agentguard_cache_miss_total {}",
438 self.cache_miss_total.get()
439 )
440 .unwrap();
441
442 writeln!(out, "# TYPE agentguard_policy_reload_total counter").unwrap();
443 writeln!(
444 out,
445 "agentguard_policy_reload_total {}",
446 self.policy_reload_total.get()
447 )
448 .unwrap();
449
450 writeln!(out, "# TYPE agentguard_pdp_error_total counter").unwrap();
451 let pdp = self.pdp_error_total.read();
452 for (fallback, c) in pdp.iter() {
453 writeln!(
454 out,
455 "agentguard_pdp_error_total{{fallback=\"{}\"}} {}",
456 escape_label(fallback),
457 c.get(),
458 )
459 .unwrap();
460 }
461
462 out
463 }
464
465 pub fn snapshot(&self) -> MetricsSnapshot {
467 let decision_total: HashMap<String, u64> = self
468 .decision_total
469 .read()
470 .iter()
471 .map(|(k, v)| (k.clone(), v.get()))
472 .collect();
473 let decision_duration: HashMap<String, (u64, f64)> = self
474 .decision_duration
475 .read()
476 .iter()
477 .map(|(k, h)| (k.clone(), (h.count(), h.sum_seconds())))
478 .collect();
479 MetricsSnapshot {
480 decision_total,
481 decision_duration,
482 delegation_mint_total: self.delegation_mint_total.get(),
483 delegation_verify_total: self
484 .delegation_verify_total
485 .read()
486 .iter()
487 .map(|(k, v)| (k.clone(), v.get()))
488 .collect(),
489 cache_hit_total: self.cache_hit_total.get(),
490 cache_miss_total: self.cache_miss_total.get(),
491 policy_reload_total: self.policy_reload_total.get(),
492 pdp_error_total: self
493 .pdp_error_total
494 .read()
495 .iter()
496 .map(|(k, v)| (k.clone(), v.get()))
497 .collect(),
498 }
499 }
500}
501
502fn escape_label(s: &str) -> String {
503 s.replace('\\', "\\\\")
504 .replace('"', "\\\"")
505 .replace('\n', "\\n")
506 .replace('\r', "\\r")
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct MetricsSnapshot {
512 pub decision_total: HashMap<String, u64>,
513 pub decision_duration: HashMap<String, (u64, f64)>,
514 pub delegation_mint_total: u64,
515 pub delegation_verify_total: HashMap<String, u64>,
516 pub cache_hit_total: u64,
517 pub cache_miss_total: u64,
518 pub policy_reload_total: u64,
519 pub pdp_error_total: HashMap<String, u64>,
520}
521
522use serde::{Deserialize, Serialize};
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn counter_increments() {
530 let c = Counter::new();
531 assert_eq!(c.inc(), 1);
532 assert_eq!(c.inc(), 2);
533 assert_eq!(c.add(5), 7);
534 }
535
536 #[test]
537 fn histogram_observes_into_buckets() {
538 let h = Histogram::new("test", &[0.1, 1.0, 10.0]);
539 h.observe(Duration::from_millis(5));
540 h.observe(Duration::from_millis(500));
541 h.observe(Duration::from_secs(20));
542 let counts = h.bucket_counts();
543 let bucket_01 = counts.iter().find(|(b, _)| *b == 0.1).unwrap().1;
547 let bucket_1 = counts.iter().find(|(b, _)| *b == 1.0).unwrap().1;
548 let bucket_10 = counts.iter().find(|(b, _)| *b == 10.0).unwrap().1;
549 let bucket_inf = counts.iter().find(|(b, _)| b.is_infinite()).unwrap().1;
550 assert_eq!(bucket_01, 1);
551 assert_eq!(bucket_1, 2);
552 assert_eq!(bucket_10, 2);
553 assert_eq!(bucket_inf, 3);
554 }
555
556 #[test]
557 fn histogram_ignores_non_finite_and_duplicate_boundaries() {
558 let h = Histogram::new(
559 "test",
560 &[
561 1.0,
562 f64::NAN,
563 f64::INFINITY,
564 0.1,
565 1.0,
566 f64::NEG_INFINITY,
567 -0.0,
568 0.0,
569 ],
570 );
571
572 h.observe(Duration::ZERO);
573 assert_eq!(
574 h.bucket_counts(),
575 vec![(-0.0, 1), (0.1, 1), (1.0, 1), (f64::INFINITY, 1)]
576 );
577 }
578
579 #[test]
580 fn histogram_with_only_non_finite_boundaries_keeps_implicit_infinity_bucket() {
581 let h = Histogram::new("test", &[f64::NAN, f64::INFINITY, f64::NEG_INFINITY]);
582
583 h.observe(Duration::from_secs(1));
584 assert_eq!(h.bucket_counts(), vec![(f64::INFINITY, 1)]);
585 }
586
587 #[test]
588 fn metrics_record_and_render() {
589 let m = Metrics::new();
590 m.record_decision(
591 "allow",
592 "policy0",
593 "send_email",
594 "tenant1",
595 Duration::from_micros(500),
596 );
597 m.record_decision(
598 "deny",
599 "policy3",
600 "shell_exec",
601 "tenant1",
602 Duration::from_micros(2000),
603 );
604 m.record_cache_hit();
605 m.record_cache_hit();
606 m.record_cache_miss();
607
608 let text = m.render_prometheus();
609 assert!(text.contains("agentguard_decision_total"));
610 assert!(text.contains("effect=\"allow\""));
611 assert!(text.contains("agentguard_cache_hit_total 2"));
612 assert!(text.contains("agentguard_cache_miss_total 1"));
613 }
614
615 #[test]
621 fn render_prometheus_empty_registry() {
622 let m = Metrics::new();
623 let text = m.render_prometheus();
624 assert!(text.contains("# HELP agentguard_decision_total"));
626 assert!(text.contains("# TYPE agentguard_decision_total counter"));
627 assert!(!text.contains("agentguard_decision_total{"));
628 assert!(text.contains("agentguard_cache_hit_total 0"));
630 }
631
632 #[test]
637 fn cardinality_cap_is_enforced() {
638 let m = Metrics::with_cap(8);
639 for i in 0..100 {
640 m.record_decision(
641 "allow",
642 "policy0",
643 &format!("action-{i}"),
644 "",
645 std::time::Duration::from_micros(i as u64),
646 );
647 }
648 let snap = m.snapshot();
649 assert_eq!(
650 snap.decision_total.len(),
651 8,
652 "cardinality cap must be enforced; got {} entries",
653 snap.decision_total.len()
654 );
655 }
656}