1use std::collections::BTreeMap;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Mutex;
14use std::time::Duration;
15
16const OUTCOMES: &[&str] = &[
20 "ok",
21 "rate_limited",
22 "over_quota",
23 "over_budget",
24 "unpriced_model",
25 "limiter_error",
26 "unauthorized",
27 "forbidden",
28 "method_not_allowed",
29 "not_found",
30 "payload_too_large",
31 "header_too_large",
32 "bad_gateway",
33 "upstream_error",
34 "upstream_timeout",
35 "upstream_body_too_large",
36 "upstream_body_error",
37 "other",
38];
39
40fn outcome_is_blocked(outcome: &str) -> bool {
45 matches!(
46 outcome,
47 "rate_limited"
48 | "over_quota"
49 | "over_budget"
50 | "unpriced_model"
51 | "unauthorized"
52 | "forbidden"
53 )
54}
55
56const RL_SCOPES: &[&str] = &["ip", "route", "key"];
58
59const WAF_RULES: &[&str] = &["sqli", "xss", "path_traversal", "custom"];
62
63const LATENCY_BUCKETS: &[f64] = &[
65 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
66];
67
68const LLM_RESULTS: &[&str] = &["metered", "unpriced", "no_usage"];
72
73const LLM_TOKEN_KINDS: &[&str] = &["input", "output", "cached", "reasoning"];
77
78const BUDGET_SCOPES: &[&str] = &["global", "key", "model", "team", "other"];
81
82const MAX_LLM_MODEL_SERIES: usize = 128;
86
87const LLM_MODEL_OVERFLOW: &str = "_over_cap";
89
90const KEYVAULT_RESULTS: &[&str] = &["swapped", "denied_key", "denied_model"];
95
96const DLP_CATEGORIES: &[&str] = &[
100 "email",
101 "credit_card",
102 "aws_key",
103 "api_key",
104 "private_key",
105 "ssn",
106 "phone",
107 "iban",
108 "high_entropy",
109 "gazetteer",
110 "person",
111 "address",
112 "org",
113 "prompt_injection",
114 "custom",
115 "other",
116];
117
118#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
122pub struct DrainedUsage {
123 pub requests: u64,
124 pub ingress_bytes: u64,
125 pub egress_bytes: u64,
126 pub tokens_in: u64,
127 pub tokens_out: u64,
128 pub cost_micros: u64,
129 pub blocked: u64,
131 pub waf_sqli: u64,
135 pub waf_xss: u64,
136 pub waf_path_traversal: u64,
137 pub waf_custom: u64,
138}
139
140impl DrainedUsage {
141 pub fn is_empty(&self) -> bool {
143 *self == DrainedUsage::default()
144 }
145}
146
147#[derive(Clone, Copy, Debug, Default)]
151pub struct LlmSample {
152 pub tokens_in: u64,
153 pub tokens_out: u64,
154 pub cached_tokens: u64,
156 pub reasoning_tokens: u64,
158 pub cost_micros: Option<u64>,
160}
161
162#[derive(Clone, Copy, Debug, Default)]
165struct PerModelCounters {
166 tokens_in: u64,
167 tokens_out: u64,
168 cached_tokens: u64,
169 reasoning_tokens: u64,
170 cost_micros: u64,
171}
172
173pub struct Metrics {
176 requests: Vec<AtomicU64>,
178 ratelimit_hits: Vec<AtomicU64>,
180 waf_hits: Vec<AtomicU64>,
182 latency_buckets: Vec<AtomicU64>,
185 latency_sum_micros: AtomicU64,
186 latency_count: AtomicU64,
187 csp_reports: AtomicU64,
188 usage_requests: AtomicU64,
192 usage_ingress_bytes: AtomicU64,
193 usage_egress_bytes: AtomicU64,
194 usage_blocked: AtomicU64,
198 usage_tokens_in: AtomicU64,
201 usage_tokens_out: AtomicU64,
202 usage_cost_micros: AtomicU64,
203 usage_waf_hits: Vec<AtomicU64>,
207 llm_tokens_in: AtomicU64,
209 llm_tokens_out: AtomicU64,
211 llm_cached_tokens: AtomicU64,
214 llm_reasoning_tokens: AtomicU64,
216 llm_cost_micros: AtomicU64,
218 llm_ttft_buckets: Vec<AtomicU64>,
223 llm_ttft_sum_micros: AtomicU64,
224 llm_ttft_count: AtomicU64,
225 llm_tpot_buckets: Vec<AtomicU64>,
228 llm_tpot_sum_micros: AtomicU64,
229 llm_tpot_count: AtomicU64,
230 llm_results: Vec<AtomicU64>,
232 per_model: Mutex<BTreeMap<String, PerModelCounters>>,
236 per_team: Mutex<BTreeMap<String, PerModelCounters>>,
240 per_key: Mutex<BTreeMap<String, PerModelCounters>>,
244 budget_blocked: Vec<AtomicU64>,
246 budget_consumed: Mutex<BTreeMap<String, f64>>,
250 keyvault_results: Vec<AtomicU64>,
252 dlp_findings: Vec<AtomicU64>,
254 dlp_blocked: AtomicU64,
256 budget_reconcile_failures: AtomicU64,
261}
262
263impl Default for Metrics {
264 fn default() -> Self {
265 Metrics {
266 requests: OUTCOMES.iter().map(|_| AtomicU64::new(0)).collect(),
267 ratelimit_hits: RL_SCOPES.iter().map(|_| AtomicU64::new(0)).collect(),
268 waf_hits: WAF_RULES.iter().map(|_| AtomicU64::new(0)).collect(),
269 latency_buckets: LATENCY_BUCKETS.iter().map(|_| AtomicU64::new(0)).collect(),
270 latency_sum_micros: AtomicU64::new(0),
271 latency_count: AtomicU64::new(0),
272 csp_reports: AtomicU64::new(0),
273 usage_requests: AtomicU64::new(0),
274 usage_ingress_bytes: AtomicU64::new(0),
275 usage_egress_bytes: AtomicU64::new(0),
276 usage_blocked: AtomicU64::new(0),
277 usage_tokens_in: AtomicU64::new(0),
278 usage_tokens_out: AtomicU64::new(0),
279 usage_cost_micros: AtomicU64::new(0),
280 usage_waf_hits: WAF_RULES.iter().map(|_| AtomicU64::new(0)).collect(),
281 llm_tokens_in: AtomicU64::new(0),
282 llm_tokens_out: AtomicU64::new(0),
283 llm_cached_tokens: AtomicU64::new(0),
284 llm_reasoning_tokens: AtomicU64::new(0),
285 llm_cost_micros: AtomicU64::new(0),
286 llm_ttft_buckets: LATENCY_BUCKETS.iter().map(|_| AtomicU64::new(0)).collect(),
287 llm_ttft_sum_micros: AtomicU64::new(0),
288 llm_ttft_count: AtomicU64::new(0),
289 llm_tpot_buckets: LATENCY_BUCKETS.iter().map(|_| AtomicU64::new(0)).collect(),
290 llm_tpot_sum_micros: AtomicU64::new(0),
291 llm_tpot_count: AtomicU64::new(0),
292 llm_results: LLM_RESULTS.iter().map(|_| AtomicU64::new(0)).collect(),
293 per_model: Mutex::new(BTreeMap::new()),
294 per_team: Mutex::new(BTreeMap::new()),
295 per_key: Mutex::new(BTreeMap::new()),
296 budget_blocked: BUDGET_SCOPES.iter().map(|_| AtomicU64::new(0)).collect(),
297 budget_consumed: Mutex::new(BTreeMap::new()),
298 keyvault_results: KEYVAULT_RESULTS.iter().map(|_| AtomicU64::new(0)).collect(),
299 dlp_findings: DLP_CATEGORIES.iter().map(|_| AtomicU64::new(0)).collect(),
300 dlp_blocked: AtomicU64::new(0),
301 budget_reconcile_failures: AtomicU64::new(0),
302 }
303 }
304}
305
306fn observe_hist(
310 buckets: &[AtomicU64],
311 sum_micros: &AtomicU64,
312 count: &AtomicU64,
313 elapsed: Duration,
314) {
315 let secs = elapsed.as_secs_f64();
316 for (i, bound) in LATENCY_BUCKETS.iter().enumerate() {
317 if secs <= *bound {
318 buckets[i].fetch_add(1, Ordering::Relaxed);
319 }
320 }
321 sum_micros.fetch_add(elapsed.as_micros() as u64, Ordering::Relaxed);
322 count.fetch_add(1, Ordering::Relaxed);
323}
324
325fn render_hist(
328 out: &mut String,
329 name: &str,
330 help: &str,
331 buckets: &[AtomicU64],
332 sum_micros: &AtomicU64,
333 count: &AtomicU64,
334) {
335 out.push_str(&format!("# HELP {name} {help}\n"));
336 out.push_str(&format!("# TYPE {name} histogram\n"));
337 for (i, bound) in LATENCY_BUCKETS.iter().enumerate() {
338 let v = buckets[i].load(Ordering::Relaxed);
339 out.push_str(&format!("{name}_bucket{{le=\"{bound}\"}} {v}\n"));
340 }
341 let c = count.load(Ordering::Relaxed);
343 out.push_str(&format!("{name}_bucket{{le=\"+Inf\"}} {c}\n"));
344 let sum_secs = sum_micros.load(Ordering::Relaxed) as f64 / 1_000_000.0;
345 out.push_str(&format!("{name}_sum {sum_secs}\n"));
346 out.push_str(&format!("{name}_count {c}\n"));
347}
348
349fn render_breakdown(
352 out: &mut String,
353 metric_prefix: &str,
354 label_name: &str,
355 dim_desc: &str,
356 map: &BTreeMap<String, PerModelCounters>,
357) {
358 out.push_str(&format!(
359 "# HELP {metric_prefix}_tokens_total LLM tokens metered by {dim_desc} and kind.\n"
360 ));
361 out.push_str(&format!("# TYPE {metric_prefix}_tokens_total counter\n"));
362 for (k, c) in map.iter() {
363 let label = escape_label(k);
364 for kind in LLM_TOKEN_KINDS {
365 let v = match *kind {
366 "input" => c.tokens_in,
367 "output" => c.tokens_out,
368 "cached" => c.cached_tokens,
369 "reasoning" => c.reasoning_tokens,
370 _ => 0,
371 };
372 out.push_str(&format!(
373 "{metric_prefix}_tokens_total{{{label_name}=\"{label}\",kind=\"{kind}\"}} {v}\n"
374 ));
375 }
376 }
377 out.push_str(&format!(
378 "# HELP {metric_prefix}_cost_microdollars_total LLM cost (micro-dollars) by {dim_desc}.\n"
379 ));
380 out.push_str(&format!(
381 "# TYPE {metric_prefix}_cost_microdollars_total counter\n"
382 ));
383 for (k, c) in map.iter() {
384 out.push_str(&format!(
385 "{metric_prefix}_cost_microdollars_total{{{label_name}=\"{}\"}} {}\n",
386 escape_label(k),
387 c.cost_micros
388 ));
389 }
390}
391
392fn accumulate_bounded(map: &Mutex<BTreeMap<String, PerModelCounters>>, key: &str, s: &LlmSample) {
396 let mut map = map.lock().expect("per-dimension mutex poisoned");
397 let k = if map.contains_key(key) || map.len() < MAX_LLM_MODEL_SERIES {
398 key
399 } else {
400 LLM_MODEL_OVERFLOW
401 };
402 let c = map.entry(k.to_string()).or_default();
403 c.tokens_in = c.tokens_in.saturating_add(s.tokens_in);
404 c.tokens_out = c.tokens_out.saturating_add(s.tokens_out);
405 c.cached_tokens = c.cached_tokens.saturating_add(s.cached_tokens);
406 c.reasoning_tokens = c.reasoning_tokens.saturating_add(s.reasoning_tokens);
407 c.cost_micros = c.cost_micros.saturating_add(s.cost_micros.unwrap_or(0));
408}
409
410impl Metrics {
411 pub fn new() -> Self {
412 Self::default()
413 }
414
415 pub fn record_request(&self, outcome: &str) {
417 let idx = OUTCOMES
418 .iter()
419 .position(|o| *o == outcome)
420 .unwrap_or(OUTCOMES.len() - 1); self.requests[idx].fetch_add(1, Ordering::Relaxed);
422 }
423
424 pub fn observe_latency(&self, elapsed: Duration) {
426 observe_hist(
427 &self.latency_buckets,
428 &self.latency_sum_micros,
429 &self.latency_count,
430 elapsed,
431 );
432 }
433
434 pub fn record_llm_latency(&self, ttft: Duration, tpot: Option<Duration>) {
441 observe_hist(
442 &self.llm_ttft_buckets,
443 &self.llm_ttft_sum_micros,
444 &self.llm_ttft_count,
445 ttft,
446 );
447 if let Some(tpot) = tpot {
448 observe_hist(
449 &self.llm_tpot_buckets,
450 &self.llm_tpot_sum_micros,
451 &self.llm_tpot_count,
452 tpot,
453 );
454 }
455 }
456
457 pub fn record_ratelimit_hit(&self, scope: &str) {
459 if let Some(idx) = RL_SCOPES.iter().position(|s| *s == scope) {
460 self.ratelimit_hits[idx].fetch_add(1, Ordering::Relaxed);
461 }
462 }
463
464 pub fn record_waf_hit(&self, class: &str) {
469 if let Some(idx) = WAF_RULES.iter().position(|c| *c == class) {
470 self.waf_hits[idx].fetch_add(1, Ordering::Relaxed);
471 self.usage_waf_hits[idx].fetch_add(1, Ordering::Relaxed);
473 }
474 }
475
476 pub fn record_csp_report(&self) {
478 self.csp_reports.fetch_add(1, Ordering::Relaxed);
479 }
480
481 pub fn add_usage_request(&self, outcome: &str) {
486 self.usage_requests.fetch_add(1, Ordering::Relaxed);
487 if outcome_is_blocked(outcome) {
488 self.usage_blocked.fetch_add(1, Ordering::Relaxed);
489 }
490 }
491
492 pub fn add_usage_bytes(&self, ingress: usize, egress: usize) {
495 self.usage_ingress_bytes
496 .fetch_add(ingress as u64, Ordering::Relaxed);
497 self.usage_egress_bytes
498 .fetch_add(egress as u64, Ordering::Relaxed);
499 }
500
501 pub fn drain_usage(&self) -> DrainedUsage {
504 debug_assert_eq!(WAF_RULES, ["sqli", "xss", "path_traversal", "custom"]);
505 DrainedUsage {
506 requests: self.usage_requests.swap(0, Ordering::Relaxed),
507 ingress_bytes: self.usage_ingress_bytes.swap(0, Ordering::Relaxed),
508 egress_bytes: self.usage_egress_bytes.swap(0, Ordering::Relaxed),
509 tokens_in: self.usage_tokens_in.swap(0, Ordering::Relaxed),
510 tokens_out: self.usage_tokens_out.swap(0, Ordering::Relaxed),
511 cost_micros: self.usage_cost_micros.swap(0, Ordering::Relaxed),
512 blocked: self.usage_blocked.swap(0, Ordering::Relaxed),
513 waf_sqli: self.usage_waf_hits[0].swap(0, Ordering::Relaxed),
515 waf_xss: self.usage_waf_hits[1].swap(0, Ordering::Relaxed),
516 waf_path_traversal: self.usage_waf_hits[2].swap(0, Ordering::Relaxed),
517 waf_custom: self.usage_waf_hits[3].swap(0, Ordering::Relaxed),
518 }
519 }
520
521 pub fn restore_usage(&self, u: &DrainedUsage) {
525 debug_assert_eq!(WAF_RULES, ["sqli", "xss", "path_traversal", "custom"]);
526 self.usage_requests.fetch_add(u.requests, Ordering::Relaxed);
527 self.usage_ingress_bytes
528 .fetch_add(u.ingress_bytes, Ordering::Relaxed);
529 self.usage_egress_bytes
530 .fetch_add(u.egress_bytes, Ordering::Relaxed);
531 self.usage_tokens_in
532 .fetch_add(u.tokens_in, Ordering::Relaxed);
533 self.usage_tokens_out
534 .fetch_add(u.tokens_out, Ordering::Relaxed);
535 self.usage_cost_micros
536 .fetch_add(u.cost_micros, Ordering::Relaxed);
537 self.usage_blocked.fetch_add(u.blocked, Ordering::Relaxed);
538 self.usage_waf_hits[0].fetch_add(u.waf_sqli, Ordering::Relaxed);
540 self.usage_waf_hits[1].fetch_add(u.waf_xss, Ordering::Relaxed);
541 self.usage_waf_hits[2].fetch_add(u.waf_path_traversal, Ordering::Relaxed);
542 self.usage_waf_hits[3].fetch_add(u.waf_custom, Ordering::Relaxed);
543 }
544
545 pub fn record_llm_usage(&self, model: &str, s: LlmSample) {
550 self.llm_tokens_in.fetch_add(s.tokens_in, Ordering::Relaxed);
551 self.llm_tokens_out
552 .fetch_add(s.tokens_out, Ordering::Relaxed);
553 self.llm_cached_tokens
554 .fetch_add(s.cached_tokens, Ordering::Relaxed);
555 self.llm_reasoning_tokens
556 .fetch_add(s.reasoning_tokens, Ordering::Relaxed);
557 self.usage_tokens_in
559 .fetch_add(s.tokens_in, Ordering::Relaxed);
560 self.usage_tokens_out
561 .fetch_add(s.tokens_out, Ordering::Relaxed);
562 let result = match s.cost_micros {
563 Some(c) => {
564 self.llm_cost_micros.fetch_add(c, Ordering::Relaxed);
565 self.usage_cost_micros.fetch_add(c, Ordering::Relaxed);
566 "metered"
567 }
568 None => "unpriced",
569 };
570 self.bump_llm_result(result);
571 self.record_per_model(model, &s);
572 }
573
574 fn record_per_model(&self, model: &str, s: &LlmSample) {
578 accumulate_bounded(&self.per_model, model, s);
579 }
580
581 pub fn record_llm_team_usage(&self, team: &str, s: &LlmSample) {
585 accumulate_bounded(&self.per_team, team, s);
586 }
587
588 pub fn record_llm_key_usage(&self, key: &str, s: &LlmSample) {
593 accumulate_bounded(&self.per_key, key, s);
594 }
595
596 pub fn record_budget_blocked(&self, scope: &str) {
598 let idx = BUDGET_SCOPES
599 .iter()
600 .position(|s| *s == scope)
601 .unwrap_or(BUDGET_SCOPES.len() - 1); self.budget_blocked[idx].fetch_add(1, Ordering::Relaxed);
603 }
604
605 pub fn record_budget_consumed(&self, name: &str, ratio: f64) {
609 if !ratio.is_finite() || ratio < 0.0 {
610 return;
611 }
612 let mut map = self
613 .budget_consumed
614 .lock()
615 .expect("budget_consumed mutex poisoned");
616 if map.contains_key(name) || map.len() < MAX_LLM_MODEL_SERIES {
619 map.insert(name.to_string(), ratio);
620 }
621 }
622
623 pub fn record_budget_reconcile_failures(&self, n: usize) {
626 if n > 0 {
627 self.budget_reconcile_failures
628 .fetch_add(n as u64, Ordering::Relaxed);
629 }
630 }
631
632 pub fn record_llm_no_usage(&self) {
635 self.bump_llm_result("no_usage");
636 }
637
638 fn bump_llm_result(&self, result: &str) {
639 if let Some(idx) = LLM_RESULTS.iter().position(|r| *r == result) {
640 self.llm_results[idx].fetch_add(1, Ordering::Relaxed);
641 }
642 }
643
644 pub fn record_keyvault(&self, result: &str) {
646 if let Some(idx) = KEYVAULT_RESULTS.iter().position(|r| *r == result) {
647 self.keyvault_results[idx].fetch_add(1, Ordering::Relaxed);
648 }
649 }
650
651 pub fn record_dlp_finding(&self, category: &str) {
653 let idx = DLP_CATEGORIES
654 .iter()
655 .position(|c| *c == category)
656 .unwrap_or(DLP_CATEGORIES.len() - 1); self.dlp_findings[idx].fetch_add(1, Ordering::Relaxed);
658 }
659
660 pub fn record_dlp_blocked(&self) {
662 self.dlp_blocked.fetch_add(1, Ordering::Relaxed);
663 }
664
665 pub fn render(&self) -> String {
667 let mut out = String::with_capacity(1024);
668
669 out.push_str("# HELP edgeguard_requests_total Total proxied requests by outcome.\n");
670 out.push_str("# TYPE edgeguard_requests_total counter\n");
671 for (i, label) in OUTCOMES.iter().enumerate() {
672 let v = self.requests[i].load(Ordering::Relaxed);
673 out.push_str(&format!(
674 "edgeguard_requests_total{{outcome=\"{label}\"}} {v}\n"
675 ));
676 }
677
678 out.push_str(
679 "# HELP edgeguard_ratelimit_hits_total Requests rejected by a rate limiter, by scope.\n",
680 );
681 out.push_str("# TYPE edgeguard_ratelimit_hits_total counter\n");
682 for (i, label) in RL_SCOPES.iter().enumerate() {
683 let v = self.ratelimit_hits[i].load(Ordering::Relaxed);
684 out.push_str(&format!(
685 "edgeguard_ratelimit_hits_total{{scope=\"{label}\"}} {v}\n"
686 ));
687 }
688
689 out.push_str(
690 "# HELP edgeguard_waf_hits_total WAF rule matches by class (report-only + blocked).\n",
691 );
692 out.push_str("# TYPE edgeguard_waf_hits_total counter\n");
693 for (i, label) in WAF_RULES.iter().enumerate() {
694 let v = self.waf_hits[i].load(Ordering::Relaxed);
695 out.push_str(&format!(
696 "edgeguard_waf_hits_total{{rule=\"{label}\"}} {v}\n"
697 ));
698 }
699
700 out.push_str("# HELP edgeguard_csp_reports_total CSP violation reports received.\n");
701 out.push_str("# TYPE edgeguard_csp_reports_total counter\n");
702 out.push_str(&format!(
703 "edgeguard_csp_reports_total {}\n",
704 self.csp_reports.load(Ordering::Relaxed)
705 ));
706
707 out.push_str(
708 "# HELP edgeguard_request_duration_seconds Request handling latency in seconds.\n",
709 );
710 out.push_str("# TYPE edgeguard_request_duration_seconds histogram\n");
711 for (i, bound) in LATENCY_BUCKETS.iter().enumerate() {
712 let v = self.latency_buckets[i].load(Ordering::Relaxed);
713 out.push_str(&format!(
714 "edgeguard_request_duration_seconds_bucket{{le=\"{bound}\"}} {v}\n"
715 ));
716 }
717 let count = self.latency_count.load(Ordering::Relaxed);
718 out.push_str(&format!(
720 "edgeguard_request_duration_seconds_bucket{{le=\"+Inf\"}} {count}\n"
721 ));
722 let sum_secs = self.latency_sum_micros.load(Ordering::Relaxed) as f64 / 1_000_000.0;
723 out.push_str(&format!(
724 "edgeguard_request_duration_seconds_sum {sum_secs}\n"
725 ));
726 out.push_str(&format!(
727 "edgeguard_request_duration_seconds_count {count}\n"
728 ));
729
730 render_hist(
733 &mut out,
734 "edgeguard_llm_ttft_seconds",
735 "Server-side time-to-first-token for streamed LLM responses, in seconds.",
736 &self.llm_ttft_buckets,
737 &self.llm_ttft_sum_micros,
738 &self.llm_ttft_count,
739 );
740 render_hist(
741 &mut out,
742 "edgeguard_llm_tpot_seconds",
743 "Mean time-per-output-token for streamed LLM responses (>1 output token), in seconds.",
744 &self.llm_tpot_buckets,
745 &self.llm_tpot_sum_micros,
746 &self.llm_tpot_count,
747 );
748
749 out.push_str("# HELP edgeguard_llm_tokens_total LLM tokens metered, by direction.\n");
752 out.push_str("# TYPE edgeguard_llm_tokens_total counter\n");
753 out.push_str(&format!(
754 "edgeguard_llm_tokens_total{{direction=\"input\"}} {}\n",
755 self.llm_tokens_in.load(Ordering::Relaxed)
756 ));
757 out.push_str(&format!(
758 "edgeguard_llm_tokens_total{{direction=\"output\"}} {}\n",
759 self.llm_tokens_out.load(Ordering::Relaxed)
760 ));
761
762 out.push_str(
766 "# HELP edgeguard_llm_cached_tokens_total Cached prompt tokens metered (subset of input).\n",
767 );
768 out.push_str("# TYPE edgeguard_llm_cached_tokens_total counter\n");
769 out.push_str(&format!(
770 "edgeguard_llm_cached_tokens_total {}\n",
771 self.llm_cached_tokens.load(Ordering::Relaxed)
772 ));
773 out.push_str(
774 "# HELP edgeguard_llm_reasoning_tokens_total Reasoning completion tokens metered (subset of output).\n",
775 );
776 out.push_str("# TYPE edgeguard_llm_reasoning_tokens_total counter\n");
777 out.push_str(&format!(
778 "edgeguard_llm_reasoning_tokens_total {}\n",
779 self.llm_reasoning_tokens.load(Ordering::Relaxed)
780 ));
781
782 out.push_str(
783 "# HELP edgeguard_llm_cost_microdollars_total Accumulated LLM cost in micro-dollars (1e-6 USD).\n",
784 );
785 out.push_str("# TYPE edgeguard_llm_cost_microdollars_total counter\n");
786 out.push_str(&format!(
787 "edgeguard_llm_cost_microdollars_total {}\n",
788 self.llm_cost_micros.load(Ordering::Relaxed)
789 ));
790
791 {
794 let map = self.per_model.lock().expect("per_model mutex poisoned");
795 render_breakdown(&mut out, "edgeguard_llm_model", "model", "model", &map);
796 }
797
798 {
801 let map = self.per_team.lock().expect("per_team mutex poisoned");
802 render_breakdown(&mut out, "edgeguard_llm_team", "team", "team", &map);
803 }
804
805 {
808 let map = self.per_key.lock().expect("per_key mutex poisoned");
809 render_breakdown(
810 &mut out,
811 "edgeguard_llm_key",
812 "key",
813 "authenticated key/principal",
814 &map,
815 );
816 }
817
818 out.push_str(
820 "# HELP edgeguard_llm_budget_blocked_total Requests blocked by a hard LLM budget, by scope.\n",
821 );
822 out.push_str("# TYPE edgeguard_llm_budget_blocked_total counter\n");
823 for (i, label) in BUDGET_SCOPES.iter().enumerate() {
824 let v = self.budget_blocked[i].load(Ordering::Relaxed);
825 out.push_str(&format!(
826 "edgeguard_llm_budget_blocked_total{{scope=\"{label}\"}} {v}\n"
827 ));
828 }
829 {
830 let map = self
831 .budget_consumed
832 .lock()
833 .expect("budget_consumed mutex poisoned");
834 out.push_str(
835 "# HELP edgeguard_llm_budget_consumed_ratio Latest consumed ratio (used/limit) per budget.\n",
836 );
837 out.push_str("# TYPE edgeguard_llm_budget_consumed_ratio gauge\n");
838 for (name, ratio) in map.iter() {
839 out.push_str(&format!(
840 "edgeguard_llm_budget_consumed_ratio{{budget=\"{}\"}} {ratio}\n",
841 escape_label(name)
842 ));
843 }
844 }
845 out.push_str(
846 "# HELP edgeguard_llm_budget_reconcile_failures_total Budget reserve->settle reconciles that failed against the shared store (counter drift).\n",
847 );
848 out.push_str("# TYPE edgeguard_llm_budget_reconcile_failures_total counter\n");
849 out.push_str(&format!(
850 "edgeguard_llm_budget_reconcile_failures_total {}\n",
851 self.budget_reconcile_failures.load(Ordering::Relaxed)
852 ));
853
854 out.push_str("# HELP edgeguard_llm_requests_total LLM requests metered, by result.\n");
855 out.push_str("# TYPE edgeguard_llm_requests_total counter\n");
856 for (i, label) in LLM_RESULTS.iter().enumerate() {
857 let v = self.llm_results[i].load(Ordering::Relaxed);
858 out.push_str(&format!(
859 "edgeguard_llm_requests_total{{result=\"{label}\"}} {v}\n"
860 ));
861 }
862
863 out.push_str(
864 "# HELP edgeguard_llm_keyvault_total Key-vault decisions by result (swap / egress denial).\n",
865 );
866 out.push_str("# TYPE edgeguard_llm_keyvault_total counter\n");
867 for (i, label) in KEYVAULT_RESULTS.iter().enumerate() {
868 let v = self.keyvault_results[i].load(Ordering::Relaxed);
869 out.push_str(&format!(
870 "edgeguard_llm_keyvault_total{{result=\"{label}\"}} {v}\n"
871 ));
872 }
873
874 out.push_str(
875 "# HELP edgeguard_llm_dlp_findings_total DLP findings (PII / secrets) by category.\n",
876 );
877 out.push_str("# TYPE edgeguard_llm_dlp_findings_total counter\n");
878 for (i, label) in DLP_CATEGORIES.iter().enumerate() {
879 let v = self.dlp_findings[i].load(Ordering::Relaxed);
880 out.push_str(&format!(
881 "edgeguard_llm_dlp_findings_total{{category=\"{label}\"}} {v}\n"
882 ));
883 }
884 out.push_str(
885 "# HELP edgeguard_llm_dlp_blocked_total Requests blocked by DLP block mode.\n",
886 );
887 out.push_str("# TYPE edgeguard_llm_dlp_blocked_total counter\n");
888 out.push_str(&format!(
889 "edgeguard_llm_dlp_blocked_total {}\n",
890 self.dlp_blocked.load(Ordering::Relaxed)
891 ));
892
893 out
894 }
895}
896
897fn escape_label(s: &str) -> String {
901 let mut out = String::with_capacity(s.len());
902 for ch in s.chars() {
903 match ch {
904 '\\' => out.push_str("\\\\"),
905 '"' => out.push_str("\\\""),
906 '\n' => out.push_str("\\n"),
907 _ => out.push(ch),
908 }
909 }
910 out
911}
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916
917 #[test]
918 fn records_and_renders_request_outcomes() {
919 let m = Metrics::new();
920 m.record_request("ok");
921 m.record_request("ok");
922 m.record_request("rate_limited");
923 m.record_request("totally_unknown");
925
926 let text = m.render();
927 assert!(
928 text.contains("edgeguard_requests_total{outcome=\"ok\"} 2"),
929 "{text}"
930 );
931 assert!(
932 text.contains("edgeguard_requests_total{outcome=\"rate_limited\"} 1"),
933 "{text}"
934 );
935 assert!(
936 text.contains("edgeguard_requests_total{outcome=\"other\"} 1"),
937 "{text}"
938 );
939 }
940
941 #[test]
942 fn latency_histogram_is_cumulative() {
943 let m = Metrics::new();
944 m.observe_latency(Duration::from_millis(3)); m.observe_latency(Duration::from_millis(40)); let text = m.render();
947 assert!(
949 text.contains("edgeguard_request_duration_seconds_bucket{le=\"0.005\"} 1"),
950 "{text}"
951 );
952 assert!(
953 text.contains("edgeguard_request_duration_seconds_bucket{le=\"0.05\"} 2"),
954 "{text}"
955 );
956 assert!(
957 text.contains("edgeguard_request_duration_seconds_bucket{le=\"+Inf\"} 2"),
958 "{text}"
959 );
960 assert!(
961 text.contains("edgeguard_request_duration_seconds_count 2"),
962 "{text}"
963 );
964 }
965
966 #[test]
967 fn llm_ttft_tpot_histograms_render() {
968 let m = Metrics::new();
969 m.record_llm_latency(Duration::from_millis(40), Some(Duration::from_millis(8)));
971 m.record_llm_latency(Duration::from_millis(3), None);
973 let text = m.render();
974 assert!(
976 text.contains("edgeguard_llm_ttft_seconds_bucket{le=\"0.005\"} 1"),
977 "{text}"
978 );
979 assert!(
980 text.contains("edgeguard_llm_ttft_seconds_bucket{le=\"0.05\"} 2"),
981 "{text}"
982 );
983 assert!(
984 text.contains("edgeguard_llm_ttft_seconds_count 2"),
985 "{text}"
986 );
987 assert!(
989 text.contains("edgeguard_llm_tpot_seconds_bucket{le=\"0.01\"} 1"),
990 "{text}"
991 );
992 assert!(
993 text.contains("edgeguard_llm_tpot_seconds_count 1"),
994 "{text}"
995 );
996 }
997
998 #[test]
999 fn ratelimit_and_csp_counters() {
1000 let m = Metrics::new();
1001 m.record_ratelimit_hit("ip");
1002 m.record_ratelimit_hit("route");
1003 m.record_ratelimit_hit("route");
1004 m.record_csp_report();
1005 let text = m.render();
1006 assert!(
1007 text.contains("edgeguard_ratelimit_hits_total{scope=\"ip\"} 1"),
1008 "{text}"
1009 );
1010 assert!(
1011 text.contains("edgeguard_ratelimit_hits_total{scope=\"route\"} 2"),
1012 "{text}"
1013 );
1014 assert!(text.contains("edgeguard_csp_reports_total 1"), "{text}");
1015 }
1016
1017 #[test]
1018 fn usage_accumulates_drains_and_restores() {
1019 let m = Metrics::new();
1020 m.add_usage_request("ok"); m.add_usage_request("forbidden"); m.add_usage_bytes(100, 250);
1023 m.add_usage_bytes(0, 50);
1024 m.record_llm_usage(
1026 "gpt-4o",
1027 LlmSample {
1028 tokens_in: 1_000,
1029 tokens_out: 400,
1030 cost_micros: Some(2_500),
1031 ..Default::default()
1032 },
1033 );
1034 let drained = m.drain_usage();
1036 assert_eq!(drained.requests, 2);
1037 assert_eq!(drained.blocked, 1); assert_eq!(drained.ingress_bytes, 100);
1039 assert_eq!(drained.egress_bytes, 300);
1040 assert_eq!(drained.tokens_in, 1_000);
1041 assert_eq!(drained.tokens_out, 400);
1042 assert_eq!(drained.cost_micros, 2_500);
1043 assert!(m.drain_usage().is_empty());
1044 m.restore_usage(&drained);
1046 assert_eq!(m.drain_usage(), drained);
1047 }
1048
1049 #[test]
1050 fn llm_token_and_cost_counters() {
1051 let m = Metrics::new();
1052 m.record_llm_usage(
1054 "gpt-4o",
1055 LlmSample {
1056 tokens_in: 100,
1057 tokens_out: 50,
1058 cached_tokens: 40,
1059 reasoning_tokens: 20,
1060 cost_micros: Some(1_250),
1061 },
1062 );
1063 m.record_llm_usage(
1065 "mystery",
1066 LlmSample {
1067 tokens_in: 10,
1068 tokens_out: 5,
1069 cost_micros: None,
1070 ..Default::default()
1071 },
1072 );
1073 m.record_llm_no_usage();
1075 let text = m.render();
1076 assert!(
1077 text.contains("edgeguard_llm_tokens_total{direction=\"input\"} 110"),
1078 "{text}"
1079 );
1080 assert!(
1081 text.contains("edgeguard_llm_tokens_total{direction=\"output\"} 55"),
1082 "{text}"
1083 );
1084 assert!(
1085 text.contains("edgeguard_llm_cached_tokens_total 40"),
1086 "{text}"
1087 );
1088 assert!(
1089 text.contains("edgeguard_llm_reasoning_tokens_total 20"),
1090 "{text}"
1091 );
1092 assert!(
1093 text.contains("edgeguard_llm_cost_microdollars_total 1250"),
1094 "{text}"
1095 );
1096 assert!(
1097 text.contains("edgeguard_llm_requests_total{result=\"metered\"} 1"),
1098 "{text}"
1099 );
1100 assert!(
1101 text.contains("edgeguard_llm_requests_total{result=\"unpriced\"} 1"),
1102 "{text}"
1103 );
1104 assert!(
1105 text.contains("edgeguard_llm_requests_total{result=\"no_usage\"} 1"),
1106 "{text}"
1107 );
1108 assert!(
1110 text.contains("edgeguard_llm_model_tokens_total{model=\"gpt-4o\",kind=\"cached\"} 40"),
1111 "{text}"
1112 );
1113 assert!(
1114 text.contains(
1115 "edgeguard_llm_model_tokens_total{model=\"gpt-4o\",kind=\"reasoning\"} 20"
1116 ),
1117 "{text}"
1118 );
1119 assert!(
1120 text.contains("edgeguard_llm_model_cost_microdollars_total{model=\"gpt-4o\"} 1250"),
1121 "{text}"
1122 );
1123 }
1124
1125 #[test]
1126 fn per_team_tokens_and_cost_are_accumulated_and_rendered() {
1127 let m = Metrics::new();
1128 m.record_llm_team_usage(
1129 "acme",
1130 &LlmSample {
1131 tokens_in: 100,
1132 tokens_out: 40,
1133 cached_tokens: 30,
1134 reasoning_tokens: 10,
1135 cost_micros: Some(77),
1136 },
1137 );
1138 m.record_llm_team_usage(
1139 "acme",
1140 &LlmSample {
1141 tokens_in: 50,
1142 tokens_out: 20,
1143 cost_micros: Some(23),
1144 ..Default::default()
1145 },
1146 );
1147 m.record_llm_team_usage(
1149 "_none",
1150 &LlmSample {
1151 tokens_in: 5,
1152 ..Default::default()
1153 },
1154 );
1155 let text = m.render();
1156 assert!(
1157 text.contains("edgeguard_llm_team_tokens_total{team=\"acme\",kind=\"input\"} 150"),
1158 "{text}"
1159 );
1160 assert!(
1161 text.contains("edgeguard_llm_team_tokens_total{team=\"acme\",kind=\"output\"} 60"),
1162 "{text}"
1163 );
1164 assert!(
1165 text.contains("edgeguard_llm_team_cost_microdollars_total{team=\"acme\"} 100"),
1166 "{text}"
1167 );
1168 assert!(
1169 text.contains("edgeguard_llm_team_tokens_total{team=\"_none\",kind=\"input\"} 5"),
1170 "{text}"
1171 );
1172 }
1173
1174 #[test]
1175 fn budget_reconcile_failures_counter_renders() {
1176 let m = Metrics::new();
1177 m.record_budget_reconcile_failures(0); m.record_budget_reconcile_failures(2);
1179 m.record_budget_reconcile_failures(1);
1180 assert!(
1181 m.render()
1182 .contains("edgeguard_llm_budget_reconcile_failures_total 3"),
1183 "{}",
1184 m.render()
1185 );
1186 }
1187
1188 #[test]
1189 fn per_key_tokens_and_cost_are_accumulated_and_rendered() {
1190 let m = Metrics::new();
1191 m.record_llm_key_usage(
1192 "key-abc",
1193 &LlmSample {
1194 tokens_in: 100,
1195 tokens_out: 40,
1196 cost_micros: Some(77),
1197 ..Default::default()
1198 },
1199 );
1200 m.record_llm_key_usage(
1202 "_anon",
1203 &LlmSample {
1204 tokens_in: 5,
1205 ..Default::default()
1206 },
1207 );
1208 let text = m.render();
1209 assert!(
1210 text.contains("edgeguard_llm_key_tokens_total{key=\"key-abc\",kind=\"input\"} 100"),
1211 "{text}"
1212 );
1213 assert!(
1214 text.contains("edgeguard_llm_key_cost_microdollars_total{key=\"key-abc\"} 77"),
1215 "{text}"
1216 );
1217 assert!(
1218 text.contains("edgeguard_llm_key_tokens_total{key=\"_anon\",kind=\"input\"} 5"),
1219 "{text}"
1220 );
1221 }
1222
1223 #[test]
1224 fn per_model_series_are_cardinality_bounded() {
1225 let m = Metrics::new();
1226 for i in 0..(MAX_LLM_MODEL_SERIES + 50) {
1229 m.record_llm_usage(
1230 &format!("model-{i}"),
1231 LlmSample {
1232 tokens_in: 1,
1233 ..Default::default()
1234 },
1235 );
1236 }
1237 let map = m.per_model.lock().unwrap();
1238 assert!(map.len() <= MAX_LLM_MODEL_SERIES + 1, "len={}", map.len());
1239 assert!(map.contains_key(LLM_MODEL_OVERFLOW));
1240 }
1241
1242 #[test]
1243 fn budget_blocked_and_consumed_metrics() {
1244 let m = Metrics::new();
1245 m.record_budget_blocked("key");
1246 m.record_budget_blocked("key");
1247 m.record_budget_blocked("team");
1248 m.record_budget_blocked("totally_unknown"); m.record_budget_consumed("daily-cap", 0.75);
1250 m.record_budget_consumed("daily-cap", 0.92); m.record_budget_consumed("nan-guard", f64::NAN); let text = m.render();
1253 assert!(
1254 text.contains("edgeguard_llm_budget_blocked_total{scope=\"key\"} 2"),
1255 "{text}"
1256 );
1257 assert!(
1258 text.contains("edgeguard_llm_budget_blocked_total{scope=\"team\"} 1"),
1259 "{text}"
1260 );
1261 assert!(
1262 text.contains("edgeguard_llm_budget_blocked_total{scope=\"other\"} 1"),
1263 "{text}"
1264 );
1265 assert!(
1266 text.contains("edgeguard_llm_budget_consumed_ratio{budget=\"daily-cap\"} 0.92"),
1267 "{text}"
1268 );
1269 assert!(
1270 !text.contains("nan-guard"),
1271 "NaN sample must be dropped: {text}"
1272 );
1273 }
1274
1275 #[test]
1276 fn label_values_are_escaped() {
1277 let m = Metrics::new();
1279 m.record_llm_usage(
1280 "evil\"\nmodel",
1281 LlmSample {
1282 tokens_in: 1,
1283 ..Default::default()
1284 },
1285 );
1286 let text = m.render();
1287 assert!(text.contains("model=\"evil\\\"\\nmodel\""), "{text}");
1288 }
1289
1290 #[test]
1291 fn dlp_finding_and_blocked_counters() {
1292 let m = Metrics::new();
1293 m.record_dlp_finding("email");
1294 m.record_dlp_finding("email");
1295 m.record_dlp_finding("api_key");
1296 m.record_dlp_finding("totally_unknown"); m.record_dlp_blocked();
1298 let text = m.render();
1299 assert!(
1300 text.contains("edgeguard_llm_dlp_findings_total{category=\"email\"} 2"),
1301 "{text}"
1302 );
1303 assert!(
1304 text.contains("edgeguard_llm_dlp_findings_total{category=\"api_key\"} 1"),
1305 "{text}"
1306 );
1307 assert!(
1308 text.contains("edgeguard_llm_dlp_findings_total{category=\"other\"} 1"),
1309 "{text}"
1310 );
1311 assert!(text.contains("edgeguard_llm_dlp_blocked_total 1"), "{text}");
1312 }
1313
1314 #[test]
1315 fn keyvault_result_counters() {
1316 let m = Metrics::new();
1317 m.record_keyvault("swapped");
1318 m.record_keyvault("swapped");
1319 m.record_keyvault("denied_model");
1320 m.record_keyvault("totally_unknown"); let text = m.render();
1322 assert!(
1323 text.contains("edgeguard_llm_keyvault_total{result=\"swapped\"} 2"),
1324 "{text}"
1325 );
1326 assert!(
1327 text.contains("edgeguard_llm_keyvault_total{result=\"denied_model\"} 1"),
1328 "{text}"
1329 );
1330 assert!(
1331 text.contains("edgeguard_llm_keyvault_total{result=\"denied_key\"} 0"),
1332 "{text}"
1333 );
1334 }
1335
1336 #[test]
1337 fn waf_hit_counters_by_class() {
1338 let m = Metrics::new();
1339 m.record_waf_hit("sqli");
1340 m.record_waf_hit("sqli");
1341 m.record_waf_hit("custom");
1342 m.record_waf_hit("totally_unknown");
1344 let text = m.render();
1345 assert!(
1346 text.contains("edgeguard_waf_hits_total{rule=\"sqli\"} 2"),
1347 "{text}"
1348 );
1349 assert!(
1350 text.contains("edgeguard_waf_hits_total{rule=\"custom\"} 1"),
1351 "{text}"
1352 );
1353 assert!(
1355 text.contains("edgeguard_waf_hits_total{rule=\"xss\"} 0"),
1356 "{text}"
1357 );
1358 }
1359}