Skip to main content

ftui_runtime/
metrics_registry.rs

1//! Prometheus-compatible metrics registry (bd-xox.3).
2//!
3//! Provides counters, gauges, and histograms that can be exported in
4//! Prometheus text exposition format via [`MetricsRegistry::render`].
5//!
6//! # Design
7//!
8//! - **Zero-allocation on the hot path**: All metric storage uses `AtomicU64`.
9//! - **Lock-free reads**: Snapshot is a single pass over atomic loads.
10//! - **Label support**: Metrics with labels use a fixed set of variants
11//!   to avoid dynamic allocation and hash maps.
12//! - **Histogram**: Uses fixed log-scale buckets for O(1) observe.
13//!
14//! # Usage
15//!
16//! ```ignore
17//! use ftui_runtime::metrics_registry::{METRICS, BuiltinCounter, BuiltinGauge, BuiltinHistogram};
18//!
19//! // Increment a counter
20//! METRICS.counter(BuiltinCounter::RenderFramesTotal).inc();
21//!
22//! // Set a gauge
23//! METRICS.gauge(BuiltinGauge::TerminalActive).set(1);
24//!
25//! // Observe a histogram value
26//! METRICS.histogram(BuiltinHistogram::RenderFrameDurationUs).observe(450);
27//!
28//! // Export Prometheus text format
29//! let output = METRICS.render();
30//! println!("{output}");
31//! ```
32
33use std::fmt;
34use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
35
36/// Global metrics registry instance.
37pub static METRICS: MetricsRegistry = MetricsRegistry::new();
38
39// ============================================================================
40// Atomic metric primitives
41// ============================================================================
42
43/// A monotonic counter (can only increase).
44#[derive(Debug)]
45pub struct Counter(AtomicU64);
46
47impl Counter {
48    const fn new() -> Self {
49        Self(AtomicU64::new(0))
50    }
51
52    /// Increment by 1.
53    pub fn inc(&self) {
54        self.0.fetch_add(1, Ordering::Relaxed);
55    }
56
57    /// Increment by `n`.
58    pub fn inc_by(&self, n: u64) {
59        self.0.fetch_add(n, Ordering::Relaxed);
60    }
61
62    /// Current value.
63    #[must_use]
64    pub fn get(&self) -> u64 {
65        self.0.load(Ordering::Relaxed)
66    }
67}
68
69/// A gauge (can go up or down).
70#[derive(Debug)]
71pub struct Gauge(AtomicI64);
72
73impl Gauge {
74    const fn new() -> Self {
75        Self(AtomicI64::new(0))
76    }
77
78    /// Set the gauge to a specific value.
79    pub fn set(&self, v: i64) {
80        self.0.store(v, Ordering::Relaxed);
81    }
82
83    /// Increment by 1.
84    pub fn inc(&self) {
85        self.0.fetch_add(1, Ordering::Relaxed);
86    }
87
88    /// Decrement by 1.
89    pub fn dec(&self) {
90        self.0.fetch_sub(1, Ordering::Relaxed);
91    }
92
93    /// Current value.
94    #[must_use]
95    pub fn get(&self) -> i64 {
96        self.0.load(Ordering::Relaxed)
97    }
98}
99
100/// Fixed-bucket histogram for latency measurements.
101///
102/// Buckets (in microseconds): 50, 100, 250, 500, 1000, 2000, 4000, 8000, 16000, +Inf.
103#[derive(Debug)]
104pub struct Histogram {
105    /// Bucket upper bounds (exclusive). Last bucket is +Inf.
106    buckets: [AtomicU64; HISTOGRAM_BUCKET_COUNT],
107    /// Sum of all observed values.
108    sum: AtomicU64,
109    /// Total number of observations.
110    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    /// Record a value. Increments the appropriate bucket, sum, and count.
138    pub fn observe(&self, value: u64) {
139        // Find the first bucket whose bound >= value
140        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    /// Current count of observations.
150    #[must_use]
151    pub fn count(&self) -> u64 {
152        self.count.load(Ordering::Relaxed)
153    }
154
155    /// Current sum of all observations.
156    #[must_use]
157    pub fn sum(&self) -> u64 {
158        self.sum.load(Ordering::Relaxed)
159    }
160
161    /// Snapshot of cumulative bucket counts.
162    #[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// ============================================================================
175// Builtin metric enums
176// ============================================================================
177
178/// Builtin counter metrics.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180#[repr(u8)]
181pub enum BuiltinCounter {
182    /// Total render frames produced.
183    RenderFramesTotal = 0,
184    /// Total ANSI sequences parsed (all types).
185    AnsiSequencesParsedTotal = 1,
186    /// Malformed ANSI sequences encountered.
187    AnsiMalformedTotal = 2,
188    /// Runtime messages processed.
189    RuntimeMessagesProcessedTotal = 3,
190    /// Effects executed (commands).
191    EffectsCommandTotal = 4,
192    /// Effects executed (subscriptions).
193    EffectsSubscriptionTotal = 5,
194    /// SLO breaches detected.
195    SloBreachesTotal = 6,
196    /// Terminal resize events.
197    TerminalResizeEventsTotal = 7,
198    /// Incremental computation cache hits.
199    IncrementalCacheHitsTotal = 8,
200    /// Incremental computation cache misses.
201    IncrementalCacheMissesTotal = 9,
202    /// VOI samples taken.
203    VoiSamplesTakenTotal = 10,
204    /// VOI samples skipped.
205    VoiSamplesSkippedTotal = 11,
206    /// BOCPD change points detected.
207    BocpdChangePointsTotal = 12,
208    /// E-process rejections.
209    EProcessRejectionsTotal = 13,
210    /// Trace/evidence schema compatibility failures.
211    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/// Builtin gauge metrics.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
278#[repr(u8)]
279pub enum BuiltinGauge {
280    /// Currently active terminal instances.
281    TerminalActive = 0,
282    /// Current e-process wealth.
283    EProcessWealth = 1,
284    /// Current degradation level (0=Full, 4=Skeleton).
285    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/// Builtin histogram metrics.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
316#[repr(u8)]
317pub enum BuiltinHistogram {
318    /// Render frame duration in microseconds.
319    RenderFrameDurationUs = 0,
320    /// Diff strategy computation duration in microseconds.
321    DiffStrategyDurationUs = 1,
322    /// Layout computation duration in microseconds.
323    LayoutComputeDurationUs = 2,
324    /// Widget render duration in microseconds.
325    WidgetRenderDurationUs = 3,
326    /// Conformal prediction interval width in microseconds.
327    ConformalIntervalWidthUs = 4,
328    /// Animation duration in milliseconds.
329    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
369// ============================================================================
370// Metrics Registry
371// ============================================================================
372
373/// Central metrics registry with fixed-slot storage.
374///
375/// All metric access is lock-free (`Ordering::Relaxed` atomics).
376/// Call [`render`](MetricsRegistry::render) to produce Prometheus text format.
377pub 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    /// Access a counter by its builtin enum.
400    #[inline]
401    pub fn counter(&self, c: BuiltinCounter) -> &Counter {
402        &self.counters[c as usize]
403    }
404
405    /// Access a gauge by its builtin enum.
406    #[inline]
407    pub fn gauge(&self, g: BuiltinGauge) -> &Gauge {
408        &self.gauges[g as usize]
409    }
410
411    /// Access a histogram by its builtin enum.
412    #[inline]
413    pub fn histogram(&self, h: BuiltinHistogram) -> &Histogram {
414        &self.histograms[h as usize]
415    }
416
417    /// Render all metrics in Prometheus text exposition format.
418    #[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    /// Render into an existing buffer (avoids allocation if reused).
426    pub fn render_to(&self, out: &mut String) {
427        // Counters
428        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        // Gauges
440        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        // Histograms
452        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            // Cumulative bucket lines
465            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            // +Inf bucket
473            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            // Sum and count
483            fmt::write(
484                out,
485                format_args!("{name}_sum {}\n{name}_count {}\n", hist.sum(), hist.count()),
486            )
487            .ok();
488        }
489    }
490
491    /// Reset all metrics to zero. Useful for testing.
492    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// ============================================================================
520// Tests
521// ============================================================================
522
523#[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); // bucket 0 (<=50)
555        h.observe(75); // bucket 1 (<=100)
556        h.observe(200); // bucket 2 (<=250)
557        h.observe(20_000); // bucket 9 (+Inf)
558
559        assert_eq!(h.count(), 4);
560        assert_eq!(h.sum(), 30 + 75 + 200 + 20_000);
561
562        let counts = h.bucket_counts();
563        // Cumulative: [1, 2, 3, 3, 3, 3, 3, 3, 3, 4]
564        assert_eq!(counts[0], 1); // <=50
565        assert_eq!(counts[1], 2); // <=100
566        assert_eq!(counts[2], 3); // <=250
567        assert_eq!(counts[9], 4); // +Inf
568    }
569
570    #[test]
571    fn histogram_boundary_values() {
572        let h = Histogram::new();
573        h.observe(50); // exactly on boundary — goes into <=50 bucket
574        h.observe(100); // exactly on <=100
575        h.observe(16_000); // exactly on <=16000
576
577        let counts = h.bucket_counts();
578        assert_eq!(counts[0], 1); // <=50
579        assert_eq!(counts[1], 2); // <=100 (cumulative)
580        assert_eq!(counts[8], 3); // <=16000 (cumulative)
581    }
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        // Use a fresh local registry to avoid test ordering issues
619        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        // Counters
628        assert!(output.contains("# TYPE ftui_render_frames_total counter"));
629        assert!(output.contains("ftui_render_frames_total 1"));
630
631        // Gauges
632        assert!(output.contains("# TYPE ftui_terminal_active gauge"));
633        assert!(output.contains("ftui_terminal_active 1"));
634
635        // Histograms
636        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        // Every HELP line should be followed by TYPE then value
648        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        // Empty histograms should still render with 0 counts
711        assert!(output.contains("ftui_render_frame_duration_us_count 0"));
712        assert!(output.contains("ftui_render_frame_duration_us_sum 0"));
713    }
714}