1use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Mutex;
9use std::time::Duration;
10
11use hdrhistogram::Histogram;
12
13use skm_core::SkillName;
14use skm_select::Confidence;
15
16#[derive(Debug, Clone)]
18pub struct MetricsSummary {
19 pub total_selections: u64,
21
22 pub total_timeouts: u64,
24
25 pub total_no_match: u64,
27
28 pub by_confidence: HashMap<String, u64>,
30
31 pub latency_p50: f64,
33 pub latency_p95: f64,
34 pub latency_p99: f64,
35
36 pub latency_mean: f64,
38
39 pub latency_min: f64,
41 pub latency_max: f64,
42
43 pub by_strategy: HashMap<String, u64>,
45
46 pub by_skill: HashMap<SkillName, u64>,
48
49 pub trigger_hit_rate: f64,
51
52 pub semantic_hit_rate: f64,
54
55 pub llm_fallback_rate: f64,
57}
58
59pub struct SelectionMetrics {
64 total_selections: AtomicU64,
66
67 total_timeouts: AtomicU64,
69
70 total_no_match: AtomicU64,
72
73 by_confidence: Mutex<HashMap<String, u64>>,
75
76 by_strategy: Mutex<HashMap<String, u64>>,
78
79 by_skill: Mutex<HashMap<SkillName, u64>>,
81
82 latency_histogram: Mutex<Histogram<u64>>,
84
85 cache_hits: AtomicU64,
87 cache_misses: AtomicU64,
88}
89
90impl Default for SelectionMetrics {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl SelectionMetrics {
97 pub fn new() -> Self {
99 Self {
100 total_selections: AtomicU64::new(0),
101 total_timeouts: AtomicU64::new(0),
102 total_no_match: AtomicU64::new(0),
103 by_confidence: Mutex::new(HashMap::new()),
104 by_strategy: Mutex::new(HashMap::new()),
105 by_skill: Mutex::new(HashMap::new()),
106 latency_histogram: Mutex::new(Histogram::new(3).unwrap()),
107 cache_hits: AtomicU64::new(0),
108 cache_misses: AtomicU64::new(0),
109 }
110 }
111
112 pub fn record_selection(
114 &self,
115 skill: &SkillName,
116 strategy: &str,
117 confidence: Confidence,
118 latency: Duration,
119 ) {
120 self.total_selections.fetch_add(1, Ordering::Relaxed);
121
122 {
124 let mut by_conf = self.by_confidence.lock().unwrap();
125 *by_conf.entry(format!("{:?}", confidence)).or_default() += 1;
126 }
127
128 {
130 let mut by_strat = self.by_strategy.lock().unwrap();
131 *by_strat.entry(strategy.to_string()).or_default() += 1;
132 }
133
134 {
136 let mut by_skill = self.by_skill.lock().unwrap();
137 *by_skill.entry(skill.clone()).or_default() += 1;
138 }
139
140 {
142 let mut hist = self.latency_histogram.lock().unwrap();
143 let micros = latency.as_micros() as u64;
144 let high = hist.high();
145 let _ = hist.record(micros.min(high));
146 }
147 }
148
149 pub fn record_timeout(&self, latency: Duration) {
151 self.total_timeouts.fetch_add(1, Ordering::Relaxed);
152
153 let mut hist = self.latency_histogram.lock().unwrap();
155 let micros = latency.as_micros() as u64;
156 let high = hist.high();
157 let _ = hist.record(micros.min(high));
158 }
159
160 pub fn record_no_match(&self, latency: Duration) {
162 self.total_selections.fetch_add(1, Ordering::Relaxed);
163 self.total_no_match.fetch_add(1, Ordering::Relaxed);
164
165 let mut by_conf = self.by_confidence.lock().unwrap();
166 *by_conf.entry("None".to_string()).or_default() += 1;
167
168 let mut hist = self.latency_histogram.lock().unwrap();
169 let micros = latency.as_micros() as u64;
170 let high = hist.high();
171 let _ = hist.record(micros.min(high));
172 }
173
174 pub fn record_cache_hit(&self) {
176 self.cache_hits.fetch_add(1, Ordering::Relaxed);
177 }
178
179 pub fn record_cache_miss(&self) {
181 self.cache_misses.fetch_add(1, Ordering::Relaxed);
182 }
183
184 pub fn summary(&self) -> MetricsSummary {
186 let hist = self.latency_histogram.lock().unwrap();
187 let by_strategy = self.by_strategy.lock().unwrap().clone();
188 let total = self.total_selections.load(Ordering::Relaxed);
189
190 let trigger_count = by_strategy.get("trigger").copied().unwrap_or(0);
192 let semantic_count = by_strategy.get("semantic").copied().unwrap_or(0);
193 let llm_count = by_strategy.get("llm").copied().unwrap_or(0);
194
195 let total_f64 = total.max(1) as f64;
196
197 MetricsSummary {
198 total_selections: total,
199 total_timeouts: self.total_timeouts.load(Ordering::Relaxed),
200 total_no_match: self.total_no_match.load(Ordering::Relaxed),
201 by_confidence: self.by_confidence.lock().unwrap().clone(),
202 latency_p50: hist.value_at_quantile(0.5) as f64 / 1000.0,
203 latency_p95: hist.value_at_quantile(0.95) as f64 / 1000.0,
204 latency_p99: hist.value_at_quantile(0.99) as f64 / 1000.0,
205 latency_mean: hist.mean() / 1000.0,
206 latency_min: hist.min() as f64 / 1000.0,
207 latency_max: hist.max() as f64 / 1000.0,
208 by_strategy: by_strategy.clone(),
209 by_skill: self.by_skill.lock().unwrap().clone(),
210 trigger_hit_rate: trigger_count as f64 / total_f64,
211 semantic_hit_rate: semantic_count as f64 / total_f64,
212 llm_fallback_rate: llm_count as f64 / total_f64,
213 }
214 }
215
216 pub fn reset(&self) {
218 self.total_selections.store(0, Ordering::Relaxed);
219 self.total_timeouts.store(0, Ordering::Relaxed);
220 self.total_no_match.store(0, Ordering::Relaxed);
221 self.cache_hits.store(0, Ordering::Relaxed);
222 self.cache_misses.store(0, Ordering::Relaxed);
223 self.by_confidence.lock().unwrap().clear();
224 self.by_strategy.lock().unwrap().clear();
225 self.by_skill.lock().unwrap().clear();
226 *self.latency_histogram.lock().unwrap() = Histogram::new(3).unwrap();
227 }
228
229 pub fn to_prometheus(&self) -> String {
234 let summary = self.summary();
235 let cache_hits = self.cache_hits.load(Ordering::Relaxed);
236 let cache_misses = self.cache_misses.load(Ordering::Relaxed);
237
238 let mut lines = Vec::new();
239
240 lines.push("# HELP skm_selections_total Total number of skill selections".to_string());
242 lines.push("# TYPE skm_selections_total counter".to_string());
243 lines.push(format!("skm_selections_total {}", summary.total_selections));
244
245 lines.push("# HELP skm_timeouts_total Total number of selection timeouts".to_string());
246 lines.push("# TYPE skm_timeouts_total counter".to_string());
247 lines.push(format!("skm_timeouts_total {}", summary.total_timeouts));
248
249 lines.push("# HELP skm_no_match_total Total selections with no matching skill".to_string());
250 lines.push("# TYPE skm_no_match_total counter".to_string());
251 lines.push(format!("skm_no_match_total {}", summary.total_no_match));
252
253 lines.push("# HELP skm_selections_by_confidence Selections by confidence level".to_string());
255 lines.push("# TYPE skm_selections_by_confidence counter".to_string());
256 for (conf, count) in &summary.by_confidence {
257 lines.push(format!(
258 "skm_selections_by_confidence{{confidence=\"{}\"}} {}",
259 conf, count
260 ));
261 }
262
263 lines.push("# HELP skm_selections_by_strategy Selections by strategy".to_string());
265 lines.push("# TYPE skm_selections_by_strategy counter".to_string());
266 for (strategy, count) in &summary.by_strategy {
267 lines.push(format!(
268 "skm_selections_by_strategy{{strategy=\"{}\"}} {}",
269 strategy, count
270 ));
271 }
272
273 lines.push("# HELP skm_skill_selections Selections per skill".to_string());
275 lines.push("# TYPE skm_skill_selections counter".to_string());
276 let mut skill_vec: Vec<_> = summary.by_skill.iter().collect();
277 skill_vec.sort_by(|a, b| b.1.cmp(a.1));
278 for (skill, count) in skill_vec.into_iter().take(20) {
279 lines.push(format!(
280 "skm_skill_selections{{skill=\"{}\"}} {}",
281 skill.as_str(),
282 count
283 ));
284 }
285
286 lines.push("# HELP skm_latency_milliseconds Selection latency in milliseconds".to_string());
288 lines.push("# TYPE skm_latency_milliseconds summary".to_string());
289 lines.push(format!(
290 "skm_latency_milliseconds{{quantile=\"0.5\"}} {:.3}",
291 summary.latency_p50
292 ));
293 lines.push(format!(
294 "skm_latency_milliseconds{{quantile=\"0.95\"}} {:.3}",
295 summary.latency_p95
296 ));
297 lines.push(format!(
298 "skm_latency_milliseconds{{quantile=\"0.99\"}} {:.3}",
299 summary.latency_p99
300 ));
301 lines.push(format!(
302 "skm_latency_milliseconds_sum {:.3}",
303 summary.latency_mean * summary.total_selections as f64
304 ));
305 lines.push(format!(
306 "skm_latency_milliseconds_count {}",
307 summary.total_selections
308 ));
309
310 lines.push("# HELP skm_trigger_hit_rate Fraction of queries resolved by trigger matching".to_string());
312 lines.push("# TYPE skm_trigger_hit_rate gauge".to_string());
313 lines.push(format!("skm_trigger_hit_rate {:.4}", summary.trigger_hit_rate));
314
315 lines.push("# HELP skm_semantic_hit_rate Fraction of queries resolved by semantic search".to_string());
316 lines.push("# TYPE skm_semantic_hit_rate gauge".to_string());
317 lines.push(format!("skm_semantic_hit_rate {:.4}", summary.semantic_hit_rate));
318
319 lines.push("# HELP skm_llm_fallback_rate Fraction of queries requiring LLM fallback".to_string());
320 lines.push("# TYPE skm_llm_fallback_rate gauge".to_string());
321 lines.push(format!("skm_llm_fallback_rate {:.4}", summary.llm_fallback_rate));
322
323 lines.push("# HELP skm_cache_hits_total Total cache hits".to_string());
325 lines.push("# TYPE skm_cache_hits_total counter".to_string());
326 lines.push(format!("skm_cache_hits_total {}", cache_hits));
327
328 lines.push("# HELP skm_cache_misses_total Total cache misses".to_string());
329 lines.push("# TYPE skm_cache_misses_total counter".to_string());
330 lines.push(format!("skm_cache_misses_total {}", cache_misses));
331
332 let total_cache = cache_hits + cache_misses;
333 if total_cache > 0 {
334 let cache_hit_rate = cache_hits as f64 / total_cache as f64;
335 lines.push("# HELP skm_cache_hit_rate Cache hit rate".to_string());
336 lines.push("# TYPE skm_cache_hit_rate gauge".to_string());
337 lines.push(format!("skm_cache_hit_rate {:.4}", cache_hit_rate));
338 }
339
340 lines.join("\n")
341 }
342
343 pub fn to_json(&self) -> serde_json::Value {
345 let summary = self.summary();
346 let cache_hits = self.cache_hits.load(Ordering::Relaxed);
347 let cache_misses = self.cache_misses.load(Ordering::Relaxed);
348
349 serde_json::json!({
350 "total_selections": summary.total_selections,
351 "total_timeouts": summary.total_timeouts,
352 "total_no_match": summary.total_no_match,
353 "latency": {
354 "p50_ms": summary.latency_p50,
355 "p95_ms": summary.latency_p95,
356 "p99_ms": summary.latency_p99,
357 "mean_ms": summary.latency_mean,
358 "min_ms": summary.latency_min,
359 "max_ms": summary.latency_max
360 },
361 "by_confidence": summary.by_confidence,
362 "by_strategy": summary.by_strategy,
363 "by_skill": summary.by_skill.iter()
364 .map(|(k, v)| (k.as_str().to_string(), *v))
365 .collect::<HashMap<String, u64>>(),
366 "hit_rates": {
367 "trigger": summary.trigger_hit_rate,
368 "semantic": summary.semantic_hit_rate,
369 "llm_fallback": summary.llm_fallback_rate
370 },
371 "cache": {
372 "hits": cache_hits,
373 "misses": cache_misses,
374 "hit_rate": if cache_hits + cache_misses > 0 {
375 cache_hits as f64 / (cache_hits + cache_misses) as f64
376 } else {
377 0.0
378 }
379 }
380 })
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_metrics_record() {
390 let metrics = SelectionMetrics::new();
391
392 let skill = SkillName::new("test-skill").unwrap();
393 metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
394 metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(20));
395 metrics.record_selection(&skill, "semantic", Confidence::Medium, Duration::from_millis(100));
396
397 let summary = metrics.summary();
398
399 assert_eq!(summary.total_selections, 3);
400 assert_eq!(*summary.by_strategy.get("trigger").unwrap_or(&0), 2);
401 assert_eq!(*summary.by_strategy.get("semantic").unwrap_or(&0), 1);
402 assert_eq!(*summary.by_skill.get(&skill).unwrap_or(&0), 3);
403 }
404
405 #[test]
406 fn test_metrics_timeout() {
407 let metrics = SelectionMetrics::new();
408
409 metrics.record_timeout(Duration::from_secs(5));
410
411 let summary = metrics.summary();
412 assert_eq!(summary.total_timeouts, 1);
413 }
414
415 #[test]
416 fn test_metrics_no_match() {
417 let metrics = SelectionMetrics::new();
418
419 metrics.record_no_match(Duration::from_millis(50));
420
421 let summary = metrics.summary();
422 assert_eq!(summary.total_selections, 1);
423 assert_eq!(summary.total_no_match, 1);
424 assert!(summary.by_confidence.contains_key("None"));
425 }
426
427 #[test]
428 fn test_metrics_reset() {
429 let metrics = SelectionMetrics::new();
430
431 let skill = SkillName::new("test").unwrap();
432 metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
433
434 assert_eq!(metrics.summary().total_selections, 1);
435
436 metrics.reset();
437
438 assert_eq!(metrics.summary().total_selections, 0);
439 }
440
441 #[test]
442 fn test_prometheus_export() {
443 let metrics = SelectionMetrics::new();
444
445 let skill = SkillName::new("test").unwrap();
446 metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
447
448 let prometheus = metrics.to_prometheus();
449
450 assert!(prometheus.contains("skm_selections_total 1"));
451 assert!(prometheus.contains("skm_selections_by_strategy{strategy=\"trigger\"}"));
452 assert!(prometheus.contains("# TYPE skm_selections_total counter"));
453 assert!(prometheus.contains("skm_trigger_hit_rate"));
454 }
455
456 #[test]
457 fn test_json_export() {
458 let metrics = SelectionMetrics::new();
459
460 let skill = SkillName::new("test").unwrap();
461 metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
462
463 let json = metrics.to_json();
464
465 assert_eq!(json["total_selections"], 1);
466 assert!(json["by_strategy"]["trigger"].as_u64().is_some());
467 }
468
469 #[test]
470 fn test_cache_metrics() {
471 let metrics = SelectionMetrics::new();
472
473 metrics.record_cache_hit();
474 metrics.record_cache_hit();
475 metrics.record_cache_miss();
476
477 let prometheus = metrics.to_prometheus();
478
479 assert!(prometheus.contains("skm_cache_hits_total 2"));
480 assert!(prometheus.contains("skm_cache_misses_total 1"));
481 }
482
483 #[test]
484 fn test_hit_rates() {
485 let metrics = SelectionMetrics::new();
486
487 let skill = SkillName::new("test").unwrap();
488 metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
490 metrics.record_selection(&skill, "trigger", Confidence::High, Duration::from_millis(10));
491 metrics.record_selection(&skill, "semantic", Confidence::Medium, Duration::from_millis(50));
492 metrics.record_selection(&skill, "llm", Confidence::Low, Duration::from_millis(500));
493
494 let summary = metrics.summary();
495
496 assert!((summary.trigger_hit_rate - 0.5).abs() < 0.01);
497 assert!((summary.semantic_hit_rate - 0.25).abs() < 0.01);
498 assert!((summary.llm_fallback_rate - 0.25).abs() < 0.01);
499 }
500}