Skip to main content

av_core/
metrics.rs

1//! Dependency-free metrics registry rendering Prometheus text exposition
2//! format. Counters and histograms only (what the SLA criteria need), all
3//! lock-free on the hot path (atomics; registration takes a short mutex).
4
5use parking_lot::Mutex;
6use std::collections::BTreeMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10/// A monotonically increasing counter.
11#[derive(Debug, Default)]
12pub struct Counter {
13    value: AtomicU64,
14}
15
16impl Counter {
17    /// Increment by 1.
18    pub fn inc(&self) {
19        self.value.fetch_add(1, Ordering::Relaxed);
20    }
21
22    /// Increment by `n`.
23    pub fn add(&self, n: u64) {
24        self.value.fetch_add(n, Ordering::Relaxed);
25    }
26
27    /// Current value.
28    pub fn get(&self) -> u64 {
29        self.value.load(Ordering::Relaxed)
30    }
31}
32
33/// Fixed-bucket latency histogram (microsecond samples).
34///
35/// Buckets are cumulative on render, per Prometheus convention.
36#[derive(Debug)]
37pub struct Histogram {
38    bounds_us: Vec<u64>,
39    buckets: Vec<AtomicU64>,
40    count: AtomicU64,
41    sum_us: AtomicU64,
42    /// Samples above every declared bucket. Represents the implicit
43    /// `le="+Inf"` bucket in Prometheus terms and lets `quantile_us`
44    /// distinguish "quantile equals the top bound" from "quantile lies
45    /// beyond the top bound".
46    overflow: AtomicU64,
47}
48
49/// Default latency bucket upper bounds in microseconds: 50µs … 10s.
50///
51/// Sized for fast internal stages (identity check, quota, sanitize,
52/// dashboard endpoints, receipt sign). Long-running histograms — the
53/// upstream `dispatch` call which includes provider streaming, and the
54/// filesystem-scan reconciler / finalize paths — must use
55/// [`WIDE_LATENCY_BOUNDS_US`] instead, or every observation lands in
56/// the `+Inf` overflow bucket and p95/p99 renders as `u64::MAX`.
57pub const DEFAULT_LATENCY_BOUNDS_US: &[u64] = &[
58    50, 100, 250, 500, 1_000, 2_000, 5_000, 8_000, 10_000, 25_000, 50_000, 100_000, 1_000_000, 10_000_000,
59];
60
61/// Wide latency bucket upper bounds in microseconds: 1ms … 300s.
62///
63/// Use for histograms that measure spans dominated by network I/O
64/// (upstream LLM streaming, reconciler recovery scans, finalisation
65/// under load). Provider p99 for GPT-4o / Claude regularly sits in the
66/// 15–90 s band; the top bound of 300 s covers pathological long
67/// contexts without saturating for realistic operator SLA use.
68pub const WIDE_LATENCY_BOUNDS_US: &[u64] = &[
69    1_000,
70    5_000,
71    10_000,
72    100_000,
73    500_000,
74    1_000_000,
75    5_000_000,
76    10_000_000,
77    30_000_000,
78    60_000_000,
79    120_000_000,
80    300_000_000,
81];
82
83impl Histogram {
84    /// Create a histogram with the given bucket upper bounds (µs, ascending).
85    pub fn new(bounds_us: &[u64]) -> Self {
86        Self {
87            bounds_us: bounds_us.to_vec(),
88            buckets: bounds_us.iter().map(|_| AtomicU64::new(0)).collect(),
89            count: AtomicU64::new(0),
90            sum_us: AtomicU64::new(0),
91            overflow: AtomicU64::new(0),
92        }
93    }
94
95    /// Record a sample in microseconds.
96    pub fn observe_us(&self, us: u64) {
97        self.count.fetch_add(1, Ordering::Relaxed);
98        self.sum_us.fetch_add(us, Ordering::Relaxed);
99        for (i, b) in self.bounds_us.iter().enumerate() {
100            if us <= *b {
101                if let Some(bucket) = self.buckets.get(i) {
102                    bucket.fetch_add(1, Ordering::Relaxed);
103                }
104                return;
105            }
106        }
107        // Sample landed above every declared bucket. Track it so the
108        // renderer can emit the standard Prometheus `le="+Inf"` bucket
109        // (bounded-bucket sum otherwise equals `count` minus the tail,
110        // which is invalid Prometheus text) and so operators can spot a
111        // regime where the true P99 is beyond the top bound.
112        self.overflow.fetch_add(1, Ordering::Relaxed);
113    }
114
115    /// Total number of samples.
116    pub fn count(&self) -> u64 {
117        self.count.load(Ordering::Relaxed)
118    }
119
120    /// Approximate quantile (µs) from bucket boundaries. Returns the upper
121    /// bound of the bucket containing quantile `q` (0.0–1.0); a target that
122    /// lands above every bounded bucket returns the sentinel below.
123    ///
124    /// A `q` whose target is served only by samples in the implicit +Inf
125    /// bucket returns `u64::MAX` as a sentinel: this signals "beyond top
126    /// bucket" instead of silently under-reporting `bounds_us.last()`.
127    pub fn quantile_us(&self, q: f64) -> u64 {
128        let total = self.count();
129        if total == 0 {
130            return 0;
131        }
132        #[allow(
133            clippy::cast_possible_truncation,
134            clippy::cast_sign_loss,
135            clippy::cast_precision_loss
136        )]
137        let target = ((total as f64) * q.clamp(0.0, 1.0)).ceil() as u64;
138        let mut cum = 0u64;
139        for (i, b) in self.bounds_us.iter().enumerate() {
140            cum += self.buckets.get(i).map_or(0, |x| x.load(Ordering::Relaxed));
141            if cum >= target {
142                return *b;
143            }
144        }
145        // Bounded buckets could not reach the target: the target must sit in
146        // the +Inf overflow bucket. Surface that with u64::MAX rather than
147        // returning bounds_us.last() and silently under-reporting.
148        if self.overflow.load(Ordering::Relaxed) > 0 {
149            return u64::MAX;
150        }
151        self.bounds_us.last().copied().unwrap_or(0)
152    }
153}
154
155enum Metric {
156    Counter(Arc<Counter>),
157    Histogram(Arc<Histogram>),
158}
159
160/// A registry mapping metric names (+ optional fixed labels) to metrics.
161#[derive(Default)]
162pub struct Registry {
163    metrics: Mutex<BTreeMap<String, (String, Metric)>>,
164    /// Track the metric KIND (counter vs histogram) per base name — the
165    /// full-key type-collision guard in `counter`/`histogram_with_bounds`
166    /// only catches same-key clashes, but Prometheus text exposition
167    /// requires that ALL variants of a base name (across every label
168    /// combination) share the same type. Two contributors registering
169    /// `av_foo_total{stage="a"}` as a counter and
170    /// `av_foo_total{stage="b"}` as a histogram would produce an
171    /// invalid `# TYPE` header and Prometheus's parser would reject
172    /// the whole scrape.
173    base_kinds: Mutex<BTreeMap<String, MetricKind>>,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177enum MetricKind {
178    Counter,
179    Histogram,
180}
181
182impl MetricKind {
183    fn label(self) -> &'static str {
184        match self {
185            Self::Counter => "counter",
186            Self::Histogram => "histogram",
187        }
188    }
189}
190
191impl Registry {
192    /// Create an empty registry.
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Register (or fetch) a counter. `key` must be a valid Prometheus series
198    /// name with optional `{label="v"}` suffix.
199    ///
200    /// Panics if `key` is already registered as a histogram; a silent
201    /// overwrite would detach the existing metric from the registry and
202    /// lose every observation collected against it. Also panics if `key`
203    /// contains a byte that would corrupt Prometheus text exposition
204    /// (cross-line bytes anywhere; `"` or `\\` in the base name — both
205    /// stay legal inside the label section) — this catches accidental
206    /// interpolation of
207    /// attacker-controlled strings into a metric key at registration
208    /// rather than surfacing corrupt scrape output later.
209    #[allow(clippy::panic)]
210    pub fn counter(&self, key: &str, help: &str) -> Arc<Counter> {
211        validate_metric_key(key);
212        self.reserve_base_kind(key, MetricKind::Counter);
213        let mut m = self.metrics.lock();
214        match m.get(key) {
215            Some((_, Metric::Counter(c))) => Arc::clone(c),
216            Some((_, Metric::Histogram(_))) => panic!(
217                "metric name conflict: {key:?} is already registered as a histogram, \
218                 cannot register as a counter",
219            ),
220            None => {
221                let c = Arc::new(Counter::default());
222                m.insert(key.to_owned(), (help.to_owned(), Metric::Counter(Arc::clone(&c))));
223                c
224            }
225        }
226    }
227
228    /// Register (or fetch) a histogram with default latency buckets.
229    ///
230    /// Panics if `key` is already registered as a counter; a silent overwrite
231    /// would detach the existing metric from the registry and lose every
232    /// observation collected against it. Same key-byte guard as
233    /// [`Self::counter`].
234    #[allow(clippy::panic)]
235    pub fn histogram(&self, key: &str, help: &str) -> Arc<Histogram> {
236        self.histogram_with_bounds(key, help, DEFAULT_LATENCY_BOUNDS_US)
237    }
238
239    /// Register (or fetch) a histogram with explicit bucket bounds. Use
240    /// [`WIDE_LATENCY_BOUNDS_US`] for spans dominated by network I/O
241    /// (upstream LLM streaming, reconciler filesystem scans); use
242    /// [`DEFAULT_LATENCY_BOUNDS_US`] for fast internal stages.
243    #[allow(clippy::panic)]
244    pub fn histogram_with_bounds(&self, key: &str, help: &str, bounds_us: &[u64]) -> Arc<Histogram> {
245        validate_metric_key(key);
246        self.reserve_base_kind(key, MetricKind::Histogram);
247        let mut m = self.metrics.lock();
248        match m.get(key) {
249            Some((_, Metric::Histogram(h))) => Arc::clone(h),
250            Some((_, Metric::Counter(_))) => panic!(
251                "metric name conflict: {key:?} is already registered as a counter, \
252                 cannot register as a histogram",
253            ),
254            None => {
255                let h = Arc::new(Histogram::new(bounds_us));
256                m.insert(
257                    key.to_owned(),
258                    (help.to_owned(), Metric::Histogram(Arc::clone(&h))),
259                );
260                h
261            }
262        }
263    }
264
265    /// Enforce that a base name (the metric name up to `{`) is only
266    /// ever registered as one metric kind across every label
267    /// combination — a Prometheus text-exposition invariant. Panics
268    /// with a clear message on mismatch. Called from `counter` and
269    /// `histogram_with_bounds` before the per-key type check.
270    #[allow(clippy::panic)]
271    fn reserve_base_kind(&self, key: &str, kind: MetricKind) {
272        let (base, _labels) = split_key(key);
273        let mut kinds = self.base_kinds.lock();
274        match kinds.get(base) {
275            Some(existing) if *existing != kind => panic!(
276                "metric base-name kind conflict: {base:?} is already registered as \
277                 `{}` at another label combination; cannot register {key:?} as `{}`. \
278                 Prometheus rejects mismatched TYPE headers across variants of the \
279                 same base name and the whole scrape becomes invalid text exposition.",
280                existing.label(),
281                kind.label(),
282            ),
283            _ => {
284                kinds.insert(base.to_owned(), kind);
285            }
286        }
287    }
288
289    /// Render the registry in Prometheus text exposition format.
290    ///
291    /// Histogram observations are stored internally in microseconds but
292    /// rendered in seconds (`le` bounds and `_sum`), per Prometheus base-unit
293    /// convention. Histogram metric names should therefore end in `_seconds`.
294    ///
295    /// Round-19: HELP text is escaped per the Prometheus text format
296    /// spec (`\` → `\\`, LF → `\n`). A future counter/histogram
297    /// registration whose HELP contained a newline would otherwise
298    /// silently corrupt the scrape response — Prometheus would parse
299    /// the remainder of the HELP text as metric samples and fail.
300    pub fn render(&self) -> String {
301        let m = self.metrics.lock();
302        let mut out = String::new();
303        let mut declared = std::collections::BTreeSet::new();
304        for (key, (help, metric)) in m.iter() {
305            let (base, labels) = split_key(key);
306            let help = escape_prom_help(help);
307            match metric {
308                Metric::Counter(c) => {
309                    if declared.insert(base.to_owned()) {
310                        out.push_str(&format!("# HELP {base} {help}\n# TYPE {base} counter\n"));
311                    }
312                    out.push_str(&format!("{key} {}\n", c.get()));
313                }
314                Metric::Histogram(h) => {
315                    if declared.insert(base.to_owned()) {
316                        out.push_str(&format!("# HELP {base} {help}\n# TYPE {base} histogram\n"));
317                    }
318                    let mut cum = 0u64;
319                    for (i, b) in h.bounds_us.iter().enumerate() {
320                        cum += h.buckets.get(i).map_or(0, |x| x.load(Ordering::Relaxed));
321                        let le = (*b as f64) / 1_000_000.0;
322                        out.push_str(&format!(
323                            "{base}_bucket{{{}le=\"{le}\"}} {cum}\n",
324                            join_labels(labels)
325                        ));
326                    }
327                    out.push_str(&format!(
328                        "{base}_bucket{{{}le=\"+Inf\"}} {}\n",
329                        join_labels(labels),
330                        h.count()
331                    ));
332                    let sum_s = (h.sum_us.load(Ordering::Relaxed) as f64) / 1_000_000.0;
333                    out.push_str(&format!(
334                        "{base}_sum{labels_block} {sum_s}\n",
335                        labels_block = labels_suffix(labels)
336                    ));
337                    out.push_str(&format!(
338                        "{base}_count{labels_block} {}\n",
339                        h.count(),
340                        labels_block = labels_suffix(labels)
341                    ));
342                }
343            }
344        }
345        out
346    }
347}
348
349/// Round-19: escape a HELP text per the Prometheus text exposition
350/// format spec. Backslash and line-feed are the only two chars the
351/// format reserves in HELP lines. A future counter/histogram
352/// registration whose HELP contained a newline would otherwise
353/// silently corrupt the scrape response — Prometheus would parse
354/// the remainder of the HELP text as metric samples and fail.
355fn escape_prom_help(help: &str) -> String {
356    let mut out = String::with_capacity(help.len());
357    for c in help.chars() {
358        match c {
359            '\\' => out.push_str("\\\\"),
360            '\n' => out.push_str("\\n"),
361            // Round-20 F3 + round-21 F4 + round-26 F4: replace CR
362            // and every other C0 control (0x00–0x1F except LF
363            // which we just escaped) with a literal space.
364            // `validate_metric_key` already refuses these bytes
365            // in metric keys; the HELP-side didn't. NUL trips
366            // promtool lint / grafana-agent; ESC (0x1B) lets a
367            // caller who controls HELP text inject ANSI codes
368            // into operator terminals via `avctl` piped
369            // `/metrics`. Round-26 F4 widens the substitution to
370            // cover DEL (0x7F) and the C1 range (0x80..=0x9F) —
371            // CSI (0x9B) is a valid single-byte ANSI escape
372            // prefix under 8-bit terminal emulation, so a HELP
373            // text carrying a `\u{9b}...` sequence renders the
374            // same way in an 8-bit-clean terminal as the C0
375            // ESC+`[` prefix we already scrub. Space is the
376            // same convention prometheus_client-python's
377            // `_ESCAPE_RE` uses.
378            c if (c as u32) < 0x20 || (c as u32) == 0x7f || (0x80..=0x9f).contains(&(c as u32)) => {
379                out.push(' ');
380            }
381            other => out.push(other),
382        }
383    }
384    out
385}
386
387/// Reject metric keys that would corrupt the Prometheus text exposition
388/// format. Legitimate keys carry label values like `{stage="identity"}` and
389/// must be allowed to contain double quotes and `=`, but a newline,
390/// carriage return, backslash, or NUL byte inside a key would split the
391/// scrape line and produce invalid text. These bytes arrive only from
392/// callers accidentally interpolating attacker-influenced strings into a
393/// series name; catch that at registration.
394#[allow(clippy::panic)]
395fn validate_metric_key(key: &str) {
396    // Round-33 F4: split the key into base + labels first, then apply
397    // strict byte checks to the base name only. Label values are
398    // enclosed in double quotes by convention (`{stage="worker_queue"}`)
399    // so a global `"` ban would panic on every labelled counter
400    // registration (see round-14 the labelled-counter tests). The
401    // real hazard is a base-name that carries `"` / `\` / `\n` / `\r`
402    // / NUL — those would produce unbalanced quotes or split-line
403    // output that Prometheus rejects as invalid text exposition.
404    // Label values carry no render-time escaper: cross-line bytes
405    // (`\n` / `\r` / NUL) anywhere in the labels section are refused
406    // at registration by the loop below; this validator is the only
407    // guard, and its job covers both the base and the labels bytes.
408    let (base, labels) = split_key(key);
409    for byte in base.bytes() {
410        if matches!(byte, b'\n' | b'\r' | b'\\' | b'"' | 0x00) {
411            panic!(
412                "metric base name {base:?} contains a byte (0x{byte:02x}) that would corrupt \
413                 Prometheus text exposition; interpolating attacker-controlled strings \
414                 into metric names is unsafe",
415            );
416        }
417    }
418    // Also refuse the raw cross-line bytes anywhere in the labels
419    // section — Prometheus parses one metric per line, so any bare
420    // `\n`, `\r`, or NUL slips a synthetic line into the scrape.
421    // Backslash and double quote stay legal here because the quotes
422    // are structural to the `l="v"` convention and label sections are
423    // built from code-controlled constants (never attacker input);
424    // labels render verbatim — there is no render-time escaper for
425    // them (only HELP text gets `escape_prom_help`).
426    for byte in labels.bytes() {
427        if matches!(byte, b'\n' | b'\r' | 0x00) {
428            panic!(
429                "metric label section {labels:?} contains a byte (0x{byte:02x}) that would \
430                 corrupt Prometheus text exposition; interpolating attacker-controlled \
431                 strings into metric labels is unsafe",
432            );
433        }
434    }
435}
436
437/// Split `name{l="v"}` into (`name`, `l="v"`); no labels → (`key`, ``).
438fn split_key(key: &str) -> (&str, &str) {
439    match key.find('{') {
440        Some(i) => {
441            let base = key.get(..i).unwrap_or(key);
442            let labels = key.get(i + 1..key.len().saturating_sub(1)).unwrap_or("");
443            (base, labels)
444        }
445        None => (key, ""),
446    }
447}
448
449fn join_labels(labels: &str) -> String {
450    if labels.is_empty() {
451        String::new()
452    } else {
453        format!("{labels},")
454    }
455}
456
457fn labels_suffix(labels: &str) -> String {
458    if labels.is_empty() {
459        String::new()
460    } else {
461        format!("{{{labels}}}")
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn counter_roundtrip() {
471        let r = Registry::new();
472        let c = r.counter("av_test_total", "test counter");
473        c.inc();
474        c.add(4);
475        assert_eq!(c.get(), 5);
476        let text = r.render();
477        assert!(text.contains("# TYPE av_test_total counter"), "{text}");
478        assert!(text.contains("av_test_total 5"), "{text}");
479    }
480
481    /// Round-19: HELP text with an embedded newline must be escaped
482    /// so the Prometheus text-format scraper does not interpret the
483    /// second half as a metric sample. Backslash must also be
484    /// escaped per the spec.
485    #[test]
486    #[allow(clippy::expect_used)]
487    fn render_escapes_newlines_and_backslashes_in_help_text() {
488        let r = Registry::new();
489        r.counter("av_dangerous_total", "line1\nline2\\path");
490        let text = r.render();
491        // A rendered HELP line must not contain the raw newline —
492        // any newline in the HELP must appear as literal `\n`.
493        let help_line = text
494            .lines()
495            .find(|line| line.starts_with("# HELP av_dangerous_total"))
496            .expect("HELP line present");
497        assert!(help_line.contains(r"\n"), "help was not escaped: {help_line:?}");
498        assert!(
499            help_line.contains(r"\\"),
500            "backslash was not escaped: {help_line:?}"
501        );
502        // And the "line2" fragment must not be on its own line.
503        assert!(
504            !text.contains("\nline2\\path"),
505            "unescaped fragment leaked into scrape: {text}"
506        );
507    }
508
509    #[test]
510    fn counter_with_labels_renders_base_name_in_type() {
511        let r = Registry::new();
512        let c = r.counter("av_drops_total{stage=\"publish\"}", "drops");
513        c.inc();
514        let text = r.render();
515        assert!(text.contains("# TYPE av_drops_total counter"), "{text}");
516        assert!(text.contains("av_drops_total{stage=\"publish\"} 1"), "{text}");
517    }
518
519    #[test]
520    fn labeled_family_is_declared_once() {
521        let r = Registry::new();
522        r.histogram("av_stage_duration_seconds{stage=\"identity\"}", "stage latency");
523        r.histogram("av_stage_duration_seconds{stage=\"quota\"}", "stage latency");
524        let text = r.render();
525        assert_eq!(
526            text.matches("# HELP av_stage_duration_seconds ").count(),
527            1,
528            "{text}"
529        );
530        assert_eq!(
531            text.matches("# TYPE av_stage_duration_seconds histogram").count(),
532            1,
533            "{text}"
534        );
535    }
536
537    #[test]
538    fn histogram_quantiles() {
539        let h = Histogram::new(DEFAULT_LATENCY_BOUNDS_US);
540        for _ in 0..99 {
541            h.observe_us(80); // ≤ 100µs bucket
542        }
543        h.observe_us(9_000); // ≤ 10ms bucket
544        assert_eq!(h.count(), 100);
545        assert_eq!(h.quantile_us(0.5), 100);
546        assert_eq!(h.quantile_us(0.99), 100);
547        assert_eq!(h.quantile_us(1.0), 10_000);
548    }
549
550    #[test]
551    fn histogram_renders_cumulative_buckets() {
552        let r = Registry::new();
553        let h = r.histogram("av_lat", "latency");
554        h.observe_us(60);
555        h.observe_us(60);
556        h.observe_us(600);
557        let text = r.render();
558        // 50µs bucket: 0, 100µs bucket: 2, ..., 1ms bucket: 3
559        assert!(text.contains("av_lat_bucket{le=\"0.0001\"} 2"), "{text}");
560        assert!(text.contains("av_lat_bucket{le=\"0.001\"} 3"), "{text}");
561        assert!(text.contains("av_lat_bucket{le=\"+Inf\"} 3"), "{text}");
562        assert!(text.contains("av_lat_count 3"), "{text}");
563    }
564
565    #[test]
566    fn same_key_returns_same_metric() {
567        let r = Registry::new();
568        let a = r.counter("x_total", "x");
569        let b = r.counter("x_total", "x");
570        a.inc();
571        assert_eq!(b.get(), 1);
572    }
573
574    #[test]
575    fn labeled_histogram_renders_labels_on_every_series_line() {
576        // Catches `split_key`, `join_labels`, and `labels_suffix` stubs.
577        // A labeled histogram must emit label pairs on _bucket, _sum, and
578        // _count — the label prefix must land BEFORE the `le=` in buckets
579        // and the entire `{labels}` block must land after _sum/_count.
580        let r = Registry::new();
581        let h = r.histogram("av_lat{route=\"chat\"}", "lat");
582        h.observe_us(60);
583        let text = r.render();
584        assert!(
585            text.contains("av_lat_bucket{route=\"chat\",le=\"0.0001\"} 1"),
586            "join_labels lost the route label: {text}"
587        );
588        assert!(
589            text.contains("av_lat_sum{route=\"chat\"} "),
590            "labels_suffix lost the route label on _sum: {text}"
591        );
592        assert!(
593            text.contains("av_lat_count{route=\"chat\"} 1"),
594            "labels_suffix lost the route label on _count: {text}"
595        );
596    }
597
598    /// Vicious bug caught in review round 18: `counter()` used to silently
599    /// insert a fresh Counter over an existing Histogram at the same key,
600    /// detaching the histogram from the registry and losing every prior
601    /// observation. A metric name registered as one type must never be
602    /// silently repurposed as the other — panic loudly instead.
603    #[test]
604    #[should_panic(expected = "kind conflict")]
605    fn counter_over_existing_histogram_panics_instead_of_overwriting() {
606        let r = Registry::new();
607        r.histogram("av_metric", "help");
608        r.counter("av_metric", "help");
609    }
610
611    #[test]
612    #[should_panic(expected = "kind conflict")]
613    fn histogram_over_existing_counter_panics_instead_of_overwriting() {
614        let r = Registry::new();
615        r.counter("av_metric", "help");
616        r.histogram("av_metric", "help");
617    }
618
619    /// Cross-label type collision on the same base name: `foo{a="1"}`
620    /// registered as counter, `foo{a="2"}` registered as histogram.
621    /// Prometheus TYPE header would be ambiguous — reject at
622    /// registration.
623    #[test]
624    #[should_panic(expected = "kind conflict")]
625    fn different_labels_same_base_name_type_collision_panics() {
626        let r = Registry::new();
627        r.counter("av_metric{shard=\"a\"}", "help");
628        r.histogram("av_metric{shard=\"b\"}", "help");
629    }
630
631    /// Same-type re-registration must still work (idempotent) — the panic
632    /// guard only fires on genuine type conflicts.
633    #[test]
634    fn same_type_re_registration_returns_existing_arc() {
635        let r = Registry::new();
636        let c1 = r.counter("av_metric", "help");
637        let c2 = r.counter("av_metric", "help");
638        c1.inc();
639        assert_eq!(c2.get(), 1, "must return the same underlying counter");
640
641        let h1 = r.histogram("av_other", "help");
642        let h2 = r.histogram("av_other", "help");
643        h1.observe_us(50);
644        assert_eq!(h2.count(), 1, "must return the same underlying histogram");
645    }
646
647    /// Round-26 F4: HELP text escaper substitutes DEL (0x7F) and every
648    /// C1 control (0x80..=0x9F) with a literal space. Round-20/21
649    /// hardened C0 already; C1 was left through and CSI (0x9B) is a
650    /// valid single-byte ANSI escape prefix under 8-bit terminal
651    /// emulation. A future counter registration with operator-
652    /// influenced HELP text (charter name / model name / SSE error
653    /// snippet) that happened to include a C1 byte could otherwise
654    /// inject terminal-escape sequences through `curl /metrics | less`.
655    #[test]
656    fn escape_prom_help_scrubs_del_and_c1_controls() {
657        let dangerous = "help\u{7f}with\u{9b}csi\u{80}c1\u{9f}end";
658        let out = escape_prom_help(dangerous);
659        for c in ['\u{7f}', '\u{9b}', '\u{80}', '\u{9f}'] {
660            assert!(!out.contains(c), "escape_prom_help left {c:?} in {out:?}");
661        }
662        // The safe chars are preserved.
663        assert!(out.contains("help"));
664        assert!(out.contains("csi"));
665        assert!(out.contains("end"));
666    }
667}
668
669#[cfg(test)]
670mod histogram_boundary_tests {
671    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
672
673    use super::*;
674
675    /// Mutation-run hardening (round 12): the overflow-bucket branch of
676    /// `quantile_us` and the seconds conversion in `_sum` rendering were
677    /// unpinned. A sample past the last bound must surface as u64::MAX
678    /// (never silently under-report as the last bound), and the rendered
679    /// sum must be the microsecond total divided by exactly 1e6.
680    #[test]
681    fn quantile_overflow_reports_max_and_sum_renders_in_seconds() {
682        let r = Registry::new();
683        let h = r.histogram_with_bounds("av_overflow_probe", "probe", &[10, 100]);
684        h.observe_us(1_000_000); // beyond the last bound: overflow bucket
685        assert_eq!(h.quantile_us(0.99), u64::MAX);
686        h.observe_us(2_000_000);
687        let text = r.render();
688        assert!(
689            text.contains("av_overflow_probe_sum 3"),
690            "sum must be 3 seconds (3_000_000 us / 1e6), got:\n{text}"
691        );
692    }
693}