1use std::fmt;
34use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
35
36pub static METRICS: MetricsRegistry = MetricsRegistry::new();
38
39#[derive(Debug)]
45pub struct Counter(AtomicU64);
46
47impl Counter {
48 const fn new() -> Self {
49 Self(AtomicU64::new(0))
50 }
51
52 pub fn inc(&self) {
54 self.0.fetch_add(1, Ordering::Relaxed);
55 }
56
57 pub fn inc_by(&self, n: u64) {
59 self.0.fetch_add(n, Ordering::Relaxed);
60 }
61
62 #[must_use]
64 pub fn get(&self) -> u64 {
65 self.0.load(Ordering::Relaxed)
66 }
67}
68
69#[derive(Debug)]
71pub struct Gauge(AtomicI64);
72
73impl Gauge {
74 const fn new() -> Self {
75 Self(AtomicI64::new(0))
76 }
77
78 pub fn set(&self, v: i64) {
80 self.0.store(v, Ordering::Relaxed);
81 }
82
83 pub fn inc(&self) {
85 self.0.fetch_add(1, Ordering::Relaxed);
86 }
87
88 pub fn dec(&self) {
90 self.0.fetch_sub(1, Ordering::Relaxed);
91 }
92
93 #[must_use]
95 pub fn get(&self) -> i64 {
96 self.0.load(Ordering::Relaxed)
97 }
98}
99
100#[derive(Debug)]
104pub struct Histogram {
105 buckets: [AtomicU64; HISTOGRAM_BUCKET_COUNT],
107 sum: AtomicU64,
109 count: AtomicU64,
111}
112
113const HISTOGRAM_BUCKET_COUNT: usize = 10;
114const HISTOGRAM_BOUNDS: [u64; HISTOGRAM_BUCKET_COUNT - 1] =
115 [50, 100, 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000];
116
117impl Histogram {
118 const fn new() -> Self {
119 Self {
120 buckets: [
121 AtomicU64::new(0),
122 AtomicU64::new(0),
123 AtomicU64::new(0),
124 AtomicU64::new(0),
125 AtomicU64::new(0),
126 AtomicU64::new(0),
127 AtomicU64::new(0),
128 AtomicU64::new(0),
129 AtomicU64::new(0),
130 AtomicU64::new(0),
131 ],
132 sum: AtomicU64::new(0),
133 count: AtomicU64::new(0),
134 }
135 }
136
137 pub fn observe(&self, value: u64) {
139 let idx = HISTOGRAM_BOUNDS
141 .iter()
142 .position(|&bound| value <= bound)
143 .unwrap_or(HISTOGRAM_BUCKET_COUNT - 1);
144 self.buckets[idx].fetch_add(1, Ordering::Relaxed);
145 self.sum.fetch_add(value, Ordering::Relaxed);
146 self.count.fetch_add(1, Ordering::Relaxed);
147 }
148
149 #[must_use]
151 pub fn count(&self) -> u64 {
152 self.count.load(Ordering::Relaxed)
153 }
154
155 #[must_use]
157 pub fn sum(&self) -> u64 {
158 self.sum.load(Ordering::Relaxed)
159 }
160
161 #[must_use]
163 pub fn bucket_counts(&self) -> [u64; HISTOGRAM_BUCKET_COUNT] {
164 let mut counts = [0u64; HISTOGRAM_BUCKET_COUNT];
165 let mut cumulative = 0u64;
166 for (i, bucket) in self.buckets.iter().enumerate() {
167 cumulative += bucket.load(Ordering::Relaxed);
168 counts[i] = cumulative;
169 }
170 counts
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180#[repr(u8)]
181pub enum BuiltinCounter {
182 RenderFramesTotal = 0,
184 AnsiSequencesParsedTotal = 1,
186 AnsiMalformedTotal = 2,
188 RuntimeMessagesProcessedTotal = 3,
190 EffectsCommandTotal = 4,
192 EffectsSubscriptionTotal = 5,
194 SloBreachesTotal = 6,
196 TerminalResizeEventsTotal = 7,
198 IncrementalCacheHitsTotal = 8,
200 IncrementalCacheMissesTotal = 9,
202 VoiSamplesTakenTotal = 10,
204 VoiSamplesSkippedTotal = 11,
206 BocpdChangePointsTotal = 12,
208 EProcessRejectionsTotal = 13,
210 TraceCompatFailuresTotal = 14,
212}
213
214impl BuiltinCounter {
215 const COUNT: usize = 15;
216
217 const ALL: [Self; Self::COUNT] = [
218 Self::RenderFramesTotal,
219 Self::AnsiSequencesParsedTotal,
220 Self::AnsiMalformedTotal,
221 Self::RuntimeMessagesProcessedTotal,
222 Self::EffectsCommandTotal,
223 Self::EffectsSubscriptionTotal,
224 Self::SloBreachesTotal,
225 Self::TerminalResizeEventsTotal,
226 Self::IncrementalCacheHitsTotal,
227 Self::IncrementalCacheMissesTotal,
228 Self::VoiSamplesTakenTotal,
229 Self::VoiSamplesSkippedTotal,
230 Self::BocpdChangePointsTotal,
231 Self::EProcessRejectionsTotal,
232 Self::TraceCompatFailuresTotal,
233 ];
234
235 fn name(self) -> &'static str {
236 match self {
237 Self::RenderFramesTotal => "ftui_render_frames_total",
238 Self::AnsiSequencesParsedTotal => "ftui_ansi_sequences_parsed_total",
239 Self::AnsiMalformedTotal => "ftui_ansi_malformed_total",
240 Self::RuntimeMessagesProcessedTotal => "ftui_runtime_messages_processed_total",
241 Self::EffectsCommandTotal => "ftui_effects_command_total",
242 Self::EffectsSubscriptionTotal => "ftui_effects_subscription_total",
243 Self::SloBreachesTotal => "ftui_slo_breaches_total",
244 Self::TerminalResizeEventsTotal => "ftui_terminal_resize_events_total",
245 Self::IncrementalCacheHitsTotal => "ftui_incremental_cache_hits_total",
246 Self::IncrementalCacheMissesTotal => "ftui_incremental_cache_misses_total",
247 Self::VoiSamplesTakenTotal => "ftui_voi_samples_taken_total",
248 Self::VoiSamplesSkippedTotal => "ftui_voi_samples_skipped_total",
249 Self::BocpdChangePointsTotal => "ftui_bocpd_change_points_total",
250 Self::EProcessRejectionsTotal => "ftui_eprocess_rejections_total",
251 Self::TraceCompatFailuresTotal => "ftui_trace_compat_failures_total",
252 }
253 }
254
255 fn help(self) -> &'static str {
256 match self {
257 Self::RenderFramesTotal => "Total render frames produced.",
258 Self::AnsiSequencesParsedTotal => "Total ANSI sequences parsed.",
259 Self::AnsiMalformedTotal => "Malformed ANSI sequences encountered.",
260 Self::RuntimeMessagesProcessedTotal => "Runtime messages processed.",
261 Self::EffectsCommandTotal => "Command effects executed.",
262 Self::EffectsSubscriptionTotal => "Subscription effects started.",
263 Self::SloBreachesTotal => "SLO breaches detected.",
264 Self::TerminalResizeEventsTotal => "Terminal resize events received.",
265 Self::IncrementalCacheHitsTotal => "Incremental computation cache hits.",
266 Self::IncrementalCacheMissesTotal => "Incremental computation cache misses.",
267 Self::VoiSamplesTakenTotal => "VOI samples taken.",
268 Self::VoiSamplesSkippedTotal => "VOI samples skipped.",
269 Self::BocpdChangePointsTotal => "BOCPD change points detected.",
270 Self::EProcessRejectionsTotal => "E-process rejections triggered.",
271 Self::TraceCompatFailuresTotal => "Trace/evidence schema compatibility failures.",
272 }
273 }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
278#[repr(u8)]
279pub enum BuiltinGauge {
280 TerminalActive = 0,
282 EProcessWealth = 1,
284 DegradationLevel = 2,
286}
287
288impl BuiltinGauge {
289 const COUNT: usize = 3;
290
291 const ALL: [Self; Self::COUNT] = [
292 Self::TerminalActive,
293 Self::EProcessWealth,
294 Self::DegradationLevel,
295 ];
296
297 fn name(self) -> &'static str {
298 match self {
299 Self::TerminalActive => "ftui_terminal_active",
300 Self::EProcessWealth => "ftui_eprocess_wealth",
301 Self::DegradationLevel => "ftui_degradation_level",
302 }
303 }
304
305 fn help(self) -> &'static str {
306 match self {
307 Self::TerminalActive => "Currently active terminal instances.",
308 Self::EProcessWealth => "Current e-process wealth value.",
309 Self::DegradationLevel => "Current degradation level (0=Full, 4=Skeleton).",
310 }
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
316#[repr(u8)]
317pub enum BuiltinHistogram {
318 RenderFrameDurationUs = 0,
320 DiffStrategyDurationUs = 1,
322 LayoutComputeDurationUs = 2,
324 WidgetRenderDurationUs = 3,
326 ConformalIntervalWidthUs = 4,
328 AnimationDurationMs = 5,
330}
331
332impl BuiltinHistogram {
333 const COUNT: usize = 6;
334
335 const ALL: [Self; Self::COUNT] = [
336 Self::RenderFrameDurationUs,
337 Self::DiffStrategyDurationUs,
338 Self::LayoutComputeDurationUs,
339 Self::WidgetRenderDurationUs,
340 Self::ConformalIntervalWidthUs,
341 Self::AnimationDurationMs,
342 ];
343
344 fn name(self) -> &'static str {
345 match self {
346 Self::RenderFrameDurationUs => "ftui_render_frame_duration_us",
347 Self::DiffStrategyDurationUs => "ftui_diff_strategy_duration_us",
348 Self::LayoutComputeDurationUs => "ftui_layout_compute_duration_us",
349 Self::WidgetRenderDurationUs => "ftui_widget_render_duration_us",
350 Self::ConformalIntervalWidthUs => "ftui_conformal_interval_width_us",
351 Self::AnimationDurationMs => "ftui_animation_duration_ms",
352 }
353 }
354
355 fn help(self) -> &'static str {
356 match self {
357 Self::RenderFrameDurationUs => "Render frame duration in microseconds.",
358 Self::DiffStrategyDurationUs => "Diff strategy computation duration in microseconds.",
359 Self::LayoutComputeDurationUs => "Layout computation duration in microseconds.",
360 Self::WidgetRenderDurationUs => "Widget render duration in microseconds.",
361 Self::ConformalIntervalWidthUs => {
362 "Conformal prediction interval width in microseconds."
363 }
364 Self::AnimationDurationMs => "Animation duration in milliseconds.",
365 }
366 }
367}
368
369pub struct MetricsRegistry {
378 counters: [Counter; BuiltinCounter::COUNT],
379 gauges: [Gauge; BuiltinGauge::COUNT],
380 histograms: [Histogram; BuiltinHistogram::COUNT],
381}
382
383impl MetricsRegistry {
384 #[allow(clippy::declare_interior_mutable_const)]
385 const NEW_COUNTER: Counter = Counter::new();
386 #[allow(clippy::declare_interior_mutable_const)]
387 const NEW_GAUGE: Gauge = Gauge::new();
388 #[allow(clippy::declare_interior_mutable_const)]
389 const NEW_HISTOGRAM: Histogram = Histogram::new();
390
391 const fn new() -> Self {
392 Self {
393 counters: [Self::NEW_COUNTER; BuiltinCounter::COUNT],
394 gauges: [Self::NEW_GAUGE; BuiltinGauge::COUNT],
395 histograms: [Self::NEW_HISTOGRAM; BuiltinHistogram::COUNT],
396 }
397 }
398
399 #[inline]
401 pub fn counter(&self, c: BuiltinCounter) -> &Counter {
402 &self.counters[c as usize]
403 }
404
405 #[inline]
407 pub fn gauge(&self, g: BuiltinGauge) -> &Gauge {
408 &self.gauges[g as usize]
409 }
410
411 #[inline]
413 pub fn histogram(&self, h: BuiltinHistogram) -> &Histogram {
414 &self.histograms[h as usize]
415 }
416
417 #[must_use]
419 pub fn render(&self) -> String {
420 let mut out = String::with_capacity(4096);
421 self.render_to(&mut out);
422 out
423 }
424
425 pub fn render_to(&self, out: &mut String) {
427 for &variant in &BuiltinCounter::ALL {
429 let val = self.counters[variant as usize].get();
430 let name = variant.name();
431 let help = variant.help();
432 fmt::write(
433 out,
434 format_args!("# HELP {name} {help}\n# TYPE {name} counter\n{name} {val}\n",),
435 )
436 .ok();
437 }
438
439 for &variant in &BuiltinGauge::ALL {
441 let val = self.gauges[variant as usize].get();
442 let name = variant.name();
443 let help = variant.help();
444 fmt::write(
445 out,
446 format_args!("# HELP {name} {help}\n# TYPE {name} gauge\n{name} {val}\n",),
447 )
448 .ok();
449 }
450
451 for &variant in &BuiltinHistogram::ALL {
453 let hist = &self.histograms[variant as usize];
454 let counts = hist.bucket_counts();
455 let name = variant.name();
456 let help = variant.help();
457
458 fmt::write(
459 out,
460 format_args!("# HELP {name} {help}\n# TYPE {name} histogram\n"),
461 )
462 .ok();
463
464 for (j, &bound) in HISTOGRAM_BOUNDS.iter().enumerate() {
466 fmt::write(
467 out,
468 format_args!("{name}_bucket{{le=\"{bound}\"}} {}\n", counts[j]),
469 )
470 .ok();
471 }
472 fmt::write(
474 out,
475 format_args!(
476 "{name}_bucket{{le=\"+Inf\"}} {}\n",
477 counts[HISTOGRAM_BUCKET_COUNT - 1]
478 ),
479 )
480 .ok();
481
482 fmt::write(
484 out,
485 format_args!("{name}_sum {}\n{name}_count {}\n", hist.sum(), hist.count()),
486 )
487 .ok();
488 }
489 }
490
491 pub fn reset(&self) {
493 for c in &self.counters {
494 c.0.store(0, Ordering::Relaxed);
495 }
496 for g in &self.gauges {
497 g.0.store(0, Ordering::Relaxed);
498 }
499 for h in &self.histograms {
500 for b in &h.buckets {
501 b.store(0, Ordering::Relaxed);
502 }
503 h.sum.store(0, Ordering::Relaxed);
504 h.count.store(0, Ordering::Relaxed);
505 }
506 }
507}
508
509impl fmt::Debug for MetricsRegistry {
510 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
511 f.debug_struct("MetricsRegistry")
512 .field("counters", &BuiltinCounter::COUNT)
513 .field("gauges", &BuiltinGauge::COUNT)
514 .field("histograms", &BuiltinHistogram::COUNT)
515 .finish()
516 }
517}
518
519#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn counter_inc_and_get() {
529 let c = Counter::new();
530 assert_eq!(c.get(), 0);
531 c.inc();
532 assert_eq!(c.get(), 1);
533 c.inc_by(5);
534 assert_eq!(c.get(), 6);
535 }
536
537 #[test]
538 fn gauge_set_inc_dec() {
539 let g = Gauge::new();
540 assert_eq!(g.get(), 0);
541 g.set(42);
542 assert_eq!(g.get(), 42);
543 g.inc();
544 assert_eq!(g.get(), 43);
545 g.dec();
546 assert_eq!(g.get(), 42);
547 g.set(-10);
548 assert_eq!(g.get(), -10);
549 }
550
551 #[test]
552 fn histogram_observe_buckets() {
553 let h = Histogram::new();
554 h.observe(30); h.observe(75); h.observe(200); h.observe(20_000); assert_eq!(h.count(), 4);
560 assert_eq!(h.sum(), 30 + 75 + 200 + 20_000);
561
562 let counts = h.bucket_counts();
563 assert_eq!(counts[0], 1); assert_eq!(counts[1], 2); assert_eq!(counts[2], 3); assert_eq!(counts[9], 4); }
569
570 #[test]
571 fn histogram_boundary_values() {
572 let h = Histogram::new();
573 h.observe(50); h.observe(100); h.observe(16_000); let counts = h.bucket_counts();
578 assert_eq!(counts[0], 1); assert_eq!(counts[1], 2); assert_eq!(counts[8], 3); }
582
583 #[test]
584 fn registry_counter_access() {
585 let reg = MetricsRegistry::new();
586 reg.counter(BuiltinCounter::RenderFramesTotal).inc();
587 reg.counter(BuiltinCounter::RenderFramesTotal).inc_by(4);
588 assert_eq!(reg.counter(BuiltinCounter::RenderFramesTotal).get(), 5);
589 }
590
591 #[test]
592 fn registry_gauge_access() {
593 let reg = MetricsRegistry::new();
594 reg.gauge(BuiltinGauge::TerminalActive).set(3);
595 assert_eq!(reg.gauge(BuiltinGauge::TerminalActive).get(), 3);
596 reg.gauge(BuiltinGauge::TerminalActive).dec();
597 assert_eq!(reg.gauge(BuiltinGauge::TerminalActive).get(), 2);
598 }
599
600 #[test]
601 fn registry_histogram_access() {
602 let reg = MetricsRegistry::new();
603 reg.histogram(BuiltinHistogram::RenderFrameDurationUs)
604 .observe(1500);
605 assert_eq!(
606 reg.histogram(BuiltinHistogram::RenderFrameDurationUs)
607 .count(),
608 1
609 );
610 assert_eq!(
611 reg.histogram(BuiltinHistogram::RenderFrameDurationUs).sum(),
612 1500
613 );
614 }
615
616 #[test]
617 fn render_contains_all_metric_types() {
618 let reg = MetricsRegistry::new();
620 reg.counter(BuiltinCounter::RenderFramesTotal).inc();
621 reg.gauge(BuiltinGauge::TerminalActive).set(1);
622 reg.histogram(BuiltinHistogram::RenderFrameDurationUs)
623 .observe(500);
624
625 let output = reg.render();
626
627 assert!(output.contains("# TYPE ftui_render_frames_total counter"));
629 assert!(output.contains("ftui_render_frames_total 1"));
630
631 assert!(output.contains("# TYPE ftui_terminal_active gauge"));
633 assert!(output.contains("ftui_terminal_active 1"));
634
635 assert!(output.contains("# TYPE ftui_render_frame_duration_us histogram"));
637 assert!(output.contains("ftui_render_frame_duration_us_bucket{le=\"500\"} 1"));
638 assert!(output.contains("ftui_render_frame_duration_us_count 1"));
639 assert!(output.contains("ftui_render_frame_duration_us_sum 500"));
640 }
641
642 #[test]
643 fn render_format_is_prometheus_compatible() {
644 let reg = MetricsRegistry::new();
645 let output = reg.render();
646
647 for line in output.lines() {
649 if line.starts_with('#') {
650 assert!(
651 line.starts_with("# HELP ") || line.starts_with("# TYPE "),
652 "Comment lines must be HELP or TYPE: {line}"
653 );
654 }
655 }
656 }
657
658 #[test]
659 fn reset_clears_all() {
660 let reg = MetricsRegistry::new();
661 reg.counter(BuiltinCounter::AnsiMalformedTotal).inc();
662 reg.gauge(BuiltinGauge::EProcessWealth).set(100);
663 reg.histogram(BuiltinHistogram::AnimationDurationMs)
664 .observe(50);
665
666 reg.reset();
667
668 assert_eq!(reg.counter(BuiltinCounter::AnsiMalformedTotal).get(), 0);
669 assert_eq!(reg.gauge(BuiltinGauge::EProcessWealth).get(), 0);
670 assert_eq!(
671 reg.histogram(BuiltinHistogram::AnimationDurationMs).count(),
672 0
673 );
674 }
675
676 #[test]
677 fn all_counter_names_unique() {
678 let mut names = Vec::new();
679 for &v in &BuiltinCounter::ALL {
680 let n = v.name();
681 assert!(!names.contains(&n), "Duplicate counter name: {n}");
682 names.push(n);
683 }
684 }
685
686 #[test]
687 fn all_gauge_names_unique() {
688 let mut names = Vec::new();
689 for &v in &BuiltinGauge::ALL {
690 let n = v.name();
691 assert!(!names.contains(&n), "Duplicate gauge name: {n}");
692 names.push(n);
693 }
694 }
695
696 #[test]
697 fn all_histogram_names_unique() {
698 let mut names = Vec::new();
699 for &v in &BuiltinHistogram::ALL {
700 let n = v.name();
701 assert!(!names.contains(&n), "Duplicate histogram name: {n}");
702 names.push(n);
703 }
704 }
705
706 #[test]
707 fn histogram_empty_render() {
708 let reg = MetricsRegistry::new();
709 let output = reg.render();
710 assert!(output.contains("ftui_render_frame_duration_us_count 0"));
712 assert!(output.contains("ftui_render_frame_duration_us_sum 0"));
713 }
714}