Skip to main content

pv_core/
metrics.rs

1//! Dependency-free metrics registry rendering Prometheus text exposition
2//! format. Counters, gauges, and histograms (what the SLA criteria need),
3//! all lock-free on the hot path (atomics; registration takes a short
4//! mutex).
5
6use parking_lot::Mutex;
7use std::collections::BTreeMap;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10
11/// A monotonically increasing counter.
12#[derive(Debug, Default)]
13pub struct Counter {
14    value: AtomicU64,
15}
16
17impl Counter {
18    /// Increment by 1.
19    pub fn inc(&self) {
20        self.value.fetch_add(1, Ordering::Relaxed);
21    }
22
23    /// Increment by `n`.
24    pub fn add(&self, n: u64) {
25        self.value.fetch_add(n, Ordering::Relaxed);
26    }
27
28    /// Current value.
29    pub fn get(&self) -> u64 {
30        self.value.load(Ordering::Relaxed)
31    }
32}
33
34/// A signed gauge whose value may go up, down, or be `set` to an
35/// absolute number. Backed by `AtomicI64` because Prometheus gauges
36/// are unrestricted-sign floats; we clamp to i64 here because every
37/// data-plane gauge in the workspace (open sessions, queue depth,
38/// spool bytes) is a non-negative count that fits comfortably in
39/// 63 bits, and the signed representation lets `sub_saturating`
40/// bottom out at 0 rather than wrap.
41#[derive(Debug, Default)]
42pub struct Gauge {
43    value: std::sync::atomic::AtomicI64,
44}
45
46impl Gauge {
47    /// Set the gauge to an absolute value. Truncates to `i64::MAX`
48    /// on overflow — no data-plane count in the workspace is even
49    /// close to that ceiling, and silently overflowing to a
50    /// negative value would produce misleading dashboards.
51    pub fn set(&self, value: u64) {
52        let clamped = i64::try_from(value).unwrap_or(i64::MAX);
53        self.value.store(clamped, Ordering::Relaxed);
54    }
55
56    /// Increment by 1. Saturates at `i64::MAX`.
57    pub fn inc(&self) {
58        self.add(1);
59    }
60
61    /// Decrement by 1. Saturates at 0 — a gauge going negative is
62    /// almost always a lifecycle bookkeeping bug (dec called twice
63    /// for one inc), and rendering `-1 open_sessions` would confuse
64    /// every dashboard downstream.
65    pub fn dec(&self) {
66        self.sub(1);
67    }
68
69    /// Increment by `n`. Saturates at `i64::MAX`.
70    pub fn add(&self, n: u64) {
71        let delta = i64::try_from(n).unwrap_or(i64::MAX);
72        // A signed `fetch_add` with a positive delta saturates
73        // silently to `i64::MIN` on overflow (two's complement
74        // wrap). Loop-CAS to a saturated add so overflow lands at
75        // `i64::MAX` — never negative.
76        let mut current = self.value.load(Ordering::Relaxed);
77        loop {
78            let next = current.saturating_add(delta);
79            match self
80                .value
81                .compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed)
82            {
83                Ok(_) => return,
84                Err(observed) => current = observed,
85            }
86        }
87    }
88
89    /// Decrement by `n`. Saturates at 0.
90    pub fn sub(&self, n: u64) {
91        let delta = i64::try_from(n).unwrap_or(i64::MAX);
92        let mut current = self.value.load(Ordering::Relaxed);
93        loop {
94            let next = current.saturating_sub(delta).max(0);
95            match self
96                .value
97                .compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed)
98            {
99                Ok(_) => return,
100                Err(observed) => current = observed,
101            }
102        }
103    }
104
105    /// Current value as an unsigned integer — the saturating math
106    /// above guarantees non-negativity so the cast never truncates
107    /// a real observation.
108    pub fn get(&self) -> u64 {
109        self.value.load(Ordering::Relaxed).max(0) as u64
110    }
111}
112
113/// Fixed-bucket latency histogram (microsecond samples).
114///
115/// Buckets are cumulative on render, per Prometheus convention.
116#[derive(Debug)]
117pub struct Histogram {
118    bounds_us: Vec<u64>,
119    buckets: Vec<AtomicU64>,
120    count: AtomicU64,
121    sum_us: AtomicU64,
122    /// Samples above every declared bucket. Represents the implicit
123    /// `le="+Inf"` bucket in Prometheus terms and lets `quantile_us`
124    /// distinguish "quantile equals the top bound" from "quantile lies
125    /// beyond the top bound".
126    overflow: AtomicU64,
127}
128
129/// Default latency bucket upper bounds in microseconds: 50µs … 10s.
130///
131/// Sized for fast internal stages (identity check, quota, sanitize,
132/// dashboard endpoints, receipt sign). Long-running histograms — the
133/// upstream `dispatch` call which includes provider streaming, and the
134/// filesystem-scan reconciler / finalize paths — must use
135/// [`WIDE_LATENCY_BOUNDS_US`] instead, or every observation lands in
136/// the `+Inf` overflow bucket and p95/p99 renders as `u64::MAX`.
137pub const DEFAULT_LATENCY_BOUNDS_US: &[u64] = &[
138    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,
139];
140
141/// Wide latency bucket upper bounds in microseconds: 1ms … 300s.
142///
143/// Use for histograms that measure spans dominated by network I/O
144/// (upstream LLM streaming, reconciler recovery scans, finalisation
145/// under load). Provider p99 for GPT-4o / Claude regularly sits in the
146/// 15–90 s band; the top bound of 300 s covers pathological long
147/// contexts without saturating for realistic operator SLA use.
148pub const WIDE_LATENCY_BOUNDS_US: &[u64] = &[
149    1_000,
150    5_000,
151    10_000,
152    100_000,
153    500_000,
154    1_000_000,
155    5_000_000,
156    10_000_000,
157    30_000_000,
158    60_000_000,
159    120_000_000,
160    300_000_000,
161];
162
163impl Histogram {
164    /// Create a histogram with the given bucket upper bounds (µs, ascending).
165    pub fn new(bounds_us: &[u64]) -> Self {
166        Self {
167            bounds_us: bounds_us.to_vec(),
168            buckets: bounds_us.iter().map(|_| AtomicU64::new(0)).collect(),
169            count: AtomicU64::new(0),
170            sum_us: AtomicU64::new(0),
171            overflow: AtomicU64::new(0),
172        }
173    }
174
175    /// Record a sample in microseconds.
176    pub fn observe_us(&self, us: u64) {
177        self.count.fetch_add(1, Ordering::Relaxed);
178        self.sum_us.fetch_add(us, Ordering::Relaxed);
179        for (i, b) in self.bounds_us.iter().enumerate() {
180            if us <= *b {
181                if let Some(bucket) = self.buckets.get(i) {
182                    bucket.fetch_add(1, Ordering::Relaxed);
183                }
184                return;
185            }
186        }
187        // Sample landed above every declared bucket. Track it so the
188        // renderer can emit the standard Prometheus `le="+Inf"` bucket
189        // (bounded-bucket sum otherwise equals `count` minus the tail,
190        // which is invalid Prometheus text) and so operators can spot a
191        // regime where the true P99 is beyond the top bound.
192        self.overflow.fetch_add(1, Ordering::Relaxed);
193    }
194
195    /// Total number of samples.
196    pub fn count(&self) -> u64 {
197        self.count.load(Ordering::Relaxed)
198    }
199
200    /// Approximate quantile (µs) from bucket boundaries. Returns the upper
201    /// bound of the bucket containing quantile `q` (0.0–1.0); a target that
202    /// lands above every bounded bucket returns the sentinel below.
203    ///
204    /// A `q` whose target is served only by samples in the implicit +Inf
205    /// bucket returns `u64::MAX` as a sentinel: this signals "beyond top
206    /// bucket" instead of silently under-reporting `bounds_us.last()`.
207    pub fn quantile_us(&self, q: f64) -> u64 {
208        let total = self.count();
209        if total == 0 {
210            return 0;
211        }
212        #[allow(
213            clippy::cast_possible_truncation,
214            clippy::cast_sign_loss,
215            clippy::cast_precision_loss
216        )]
217        // Clamp to >= 1: with q = 0.0 the raw target is 0 and `cum >= 0`
218        // holds before any sample is accumulated, which would report the
219        // first bucket bound even when that bucket is empty. p0 must be
220        // the first bucket that actually contains a sample.
221        let target = (((total as f64) * q.clamp(0.0, 1.0)).ceil() as u64).max(1);
222        let mut cum = 0u64;
223        for (i, b) in self.bounds_us.iter().enumerate() {
224            cum += self.buckets.get(i).map_or(0, |x| x.load(Ordering::Relaxed));
225            if cum >= target {
226                return *b;
227            }
228        }
229        // Bounded buckets could not reach the target: the target must sit in
230        // the +Inf overflow bucket. Surface that with u64::MAX rather than
231        // returning bounds_us.last() and silently under-reporting.
232        if self.overflow.load(Ordering::Relaxed) > 0 {
233            return u64::MAX;
234        }
235        self.bounds_us.last().copied().unwrap_or(0)
236    }
237}
238
239enum Metric {
240    Counter(Arc<Counter>),
241    Gauge(Arc<Gauge>),
242    Histogram(Arc<Histogram>),
243}
244
245/// A registry mapping metric names (+ optional fixed labels) to metrics.
246#[derive(Default)]
247pub struct Registry {
248    metrics: Mutex<BTreeMap<String, (String, Metric)>>,
249    /// Track the metric KIND (counter vs histogram) per base name — the
250    /// full-key type-collision guard in `counter`/`histogram_with_bounds`
251    /// only catches same-key clashes, but Prometheus text exposition
252    /// requires that ALL variants of a base name (across every label
253    /// combination) share the same type. Two contributors registering
254    /// `pv_foo_total{stage="a"}` as a counter and
255    /// `pv_foo_total{stage="b"}` as a histogram would produce an
256    /// invalid `# TYPE` header and Prometheus's parser would reject
257    /// the whole scrape.
258    base_kinds: Mutex<BTreeMap<String, MetricKind>>,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262enum MetricKind {
263    Counter,
264    Gauge,
265    Histogram,
266    /// Not a registrable kind: a name reserved because a histogram's
267    /// text exposition generates it (`<base>_bucket`, `<base>_sum`,
268    /// `<base>_count`). Conflicts with EVERY registration — including
269    /// another histogram — since any metric under this name would
270    /// emit series/TYPE lines colliding with the generating
271    /// histogram's rendered samples.
272    HistogramReserved,
273}
274
275impl MetricKind {
276    fn label(self) -> &'static str {
277        match self {
278            Self::Counter => "counter",
279            Self::Gauge => "gauge",
280            Self::Histogram => "histogram",
281            Self::HistogramReserved => "histogram-generated series (_bucket/_sum/_count)",
282        }
283    }
284}
285
286impl Registry {
287    /// Create an empty registry.
288    pub fn new() -> Self {
289        Self::default()
290    }
291
292    /// Register (or fetch) a counter. `key` must be a valid Prometheus series
293    /// name with optional `{label="v"}` suffix.
294    ///
295    /// Panics if `key` is already registered as a histogram; a silent
296    /// overwrite would detach the existing metric from the registry and
297    /// lose every observation collected against it. Also panics if `key`
298    /// contains a byte that would corrupt Prometheus text exposition
299    /// (cross-line bytes anywhere; `"` or `\\` in the base name — both
300    /// stay legal inside the label section) — this catches accidental
301    /// interpolation of
302    /// attacker-controlled strings into a metric key at registration
303    /// rather than surfacing corrupt scrape output later.
304    #[allow(clippy::panic)]
305    pub fn counter(&self, key: &str, help: &str) -> Arc<Counter> {
306        validate_metric_key(key);
307        self.reserve_base_kind(key, MetricKind::Counter);
308        let mut m = self.metrics.lock();
309        match m.get(key) {
310            Some((existing_help, Metric::Counter(c))) => {
311                // R73 F4 (landed R74): warn on HELP-string drift
312                // across registration sites. `MetricRegistry` is
313                // first-wins on HELP, so a rich forensic HELP
314                // registered at the actual observation site
315                // (e.g. `reconciler.rs`) is silently shadowed by
316                // an earlier terse HELP at boot (e.g.
317                // `pipeline.rs`), quietly defeating alerting
318                // playbooks that key on the descriptive text.
319                // Debug-only warn (not panic) so a drifting
320                // refactor is visible in tracing output but doesn't
321                // break the boot path — the correct fix is to
322                // unify the HELP text across the two sites (extract
323                // to a `const`), and the warn surfaces exactly
324                // which pairs still drift.
325                if cfg!(debug_assertions) && existing_help != help {
326                    tracing::warn!(
327                        target: "pv_core::metrics",
328                        metric = key,
329                        existing_help = %existing_help,
330                        new_help = %help,
331                        "counter registered with divergent HELP; first-wins semantic — extract HELP to a shared const to eliminate drift"
332                    );
333                }
334                Arc::clone(c)
335            }
336            Some((_, Metric::Gauge(_))) => panic!(
337                "metric name conflict: {key:?} is already registered as a gauge, \
338                 cannot register as a counter",
339            ),
340            Some((_, Metric::Histogram(_))) => panic!(
341                "metric name conflict: {key:?} is already registered as a histogram, \
342                 cannot register as a counter",
343            ),
344            None => {
345                let c = Arc::new(Counter::default());
346                m.insert(key.to_owned(), (help.to_owned(), Metric::Counter(Arc::clone(&c))));
347                c
348            }
349        }
350    }
351
352    /// Register (or fetch) a gauge. Same key/label conventions as
353    /// [`Self::counter`]; gauges may go up, down, and be reset via
354    /// [`Gauge::set`]. Panics on kind conflict with an existing
355    /// counter or histogram registered under the same base name.
356    #[allow(clippy::panic)]
357    pub fn gauge(&self, key: &str, help: &str) -> Arc<Gauge> {
358        validate_metric_key(key);
359        self.reserve_base_kind(key, MetricKind::Gauge);
360        let mut m = self.metrics.lock();
361        match m.get(key) {
362            Some((existing_help, Metric::Gauge(g))) => {
363                if cfg!(debug_assertions) && existing_help != help {
364                    tracing::warn!(
365                        target: "pv_core::metrics",
366                        metric = key,
367                        existing_help = %existing_help,
368                        new_help = %help,
369                        "gauge registered with divergent HELP; first-wins semantic — extract HELP to a shared const to eliminate drift"
370                    );
371                }
372                Arc::clone(g)
373            }
374            Some((_, Metric::Counter(_))) => panic!(
375                "metric name conflict: {key:?} is already registered as a counter, \
376                 cannot register as a gauge",
377            ),
378            Some((_, Metric::Histogram(_))) => panic!(
379                "metric name conflict: {key:?} is already registered as a histogram, \
380                 cannot register as a gauge",
381            ),
382            None => {
383                let g = Arc::new(Gauge::default());
384                m.insert(key.to_owned(), (help.to_owned(), Metric::Gauge(Arc::clone(&g))));
385                g
386            }
387        }
388    }
389
390    /// Register (or fetch) a histogram with default latency buckets.
391    ///
392    /// Panics if `key` is already registered as a counter; a silent overwrite
393    /// would detach the existing metric from the registry and lose every
394    /// observation collected against it. Same key-byte guard as
395    /// [`Self::counter`].
396    #[allow(clippy::panic)]
397    pub fn histogram(&self, key: &str, help: &str) -> Arc<Histogram> {
398        self.histogram_with_bounds(key, help, DEFAULT_LATENCY_BOUNDS_US)
399    }
400
401    /// Register (or fetch) a histogram with explicit bucket bounds. Use
402    /// [`WIDE_LATENCY_BOUNDS_US`] for spans dominated by network I/O
403    /// (upstream LLM streaming, reconciler filesystem scans); use
404    /// [`DEFAULT_LATENCY_BOUNDS_US`] for fast internal stages.
405    ///
406    /// # Panics
407    ///
408    /// Panics if `key` is already registered as a counter; a silent
409    /// overwrite would detach the existing metric from the registry
410    /// and lose every observation collected against it. Same
411    /// key-byte guard as [`Self::counter`] applies.
412    #[allow(clippy::panic)]
413    pub fn histogram_with_bounds(&self, key: &str, help: &str, bounds_us: &[u64]) -> Arc<Histogram> {
414        validate_metric_key(key);
415        self.reserve_base_kind(key, MetricKind::Histogram);
416        let mut m = self.metrics.lock();
417        match m.get(key) {
418            Some((existing_help, Metric::Histogram(h))) => {
419                if cfg!(debug_assertions) && existing_help != help {
420                    tracing::warn!(
421                        target: "pv_core::metrics",
422                        metric = key,
423                        existing_help = %existing_help,
424                        new_help = %help,
425                        "histogram registered with divergent HELP; first-wins semantic — extract HELP to a shared const to eliminate drift"
426                    );
427                }
428                Arc::clone(h)
429            }
430            Some((_, Metric::Counter(_))) => panic!(
431                "metric name conflict: {key:?} is already registered as a counter, \
432                 cannot register as a histogram",
433            ),
434            Some((_, Metric::Gauge(_))) => panic!(
435                "metric name conflict: {key:?} is already registered as a gauge, \
436                 cannot register as a histogram",
437            ),
438            None => {
439                let h = Arc::new(Histogram::new(bounds_us));
440                m.insert(
441                    key.to_owned(),
442                    (help.to_owned(), Metric::Histogram(Arc::clone(&h))),
443                );
444                h
445            }
446        }
447    }
448
449    /// Enforce that a base name (the metric name up to `{`) is only
450    /// ever registered as one metric kind across every label
451    /// combination — a Prometheus text-exposition invariant. Panics
452    /// with a clear message on mismatch. Called from `counter` and
453    /// `histogram_with_bounds` before the per-key type check.
454    #[allow(clippy::panic)]
455    fn reserve_base_kind(&self, key: &str, kind: MetricKind) {
456        let (base, _labels) = split_key(key);
457        let mut kinds = self.base_kinds.lock();
458        // A histogram's exposition OWNS three generated series names
459        // beyond its base: `<base>_bucket`, `<base>_sum`,
460        // `<base>_count`. A counter registered later as, say,
461        // `latency_seconds_count` would render a second bare
462        // `latency_seconds_count` sample under its own TYPE header —
463        // duplicate series + conflicting TYPE claims in one scrape,
464        // which Prometheus rejects wholesale. Reserve the generated
465        // names alongside the base; the same loop symmetrically
466        // panics when a histogram's generated names collide with
467        // anything already registered (either order fails fast).
468        let reserved: Vec<(String, MetricKind)> = match kind {
469            MetricKind::Histogram => vec![
470                (base.to_owned(), kind),
471                (format!("{base}_bucket"), MetricKind::HistogramReserved),
472                (format!("{base}_sum"), MetricKind::HistogramReserved),
473                (format!("{base}_count"), MetricKind::HistogramReserved),
474            ],
475            _ => vec![(base.to_owned(), kind)],
476        };
477        for (name, reserve_as) in reserved {
478            match kinds.get(name.as_str()) {
479                Some(existing) if *existing != reserve_as => panic!(
480                    "metric base-name kind conflict: {name:?} is already registered as \
481                     `{}`; cannot register {key:?} as `{}`. \
482                     Prometheus rejects mismatched TYPE headers across variants of the \
483                     same base name and the whole scrape becomes invalid text exposition.",
484                    existing.label(),
485                    reserve_as.label(),
486                ),
487                _ => {
488                    kinds.insert(name, reserve_as);
489                }
490            }
491        }
492    }
493
494    /// Render the registry in Prometheus text exposition format.
495    ///
496    /// Histogram observations are stored internally in microseconds but
497    /// rendered in seconds (`le` bounds and `_sum`), per Prometheus base-unit
498    /// convention. Histogram metric names should therefore end in `_seconds`.
499    ///
500    /// HELP text is escaped per the Prometheus text format
501    /// spec (`\` → `\\`, LF → `\n`). A future counter/histogram
502    /// registration whose HELP contained a newline would otherwise
503    /// silently corrupt the scrape response — Prometheus would parse
504    /// the remainder of the HELP text as metric samples and fail.
505    pub fn render(&self) -> String {
506        let m = self.metrics.lock();
507        let mut out = String::new();
508        let mut declared = std::collections::BTreeSet::new();
509        for (key, (help, metric)) in m.iter() {
510            let (base, labels) = split_key(key);
511            let help = escape_prom_help(help);
512            match metric {
513                Metric::Counter(c) => {
514                    if declared.insert(base.to_owned()) {
515                        out.push_str(&format!("# HELP {base} {help}\n# TYPE {base} counter\n"));
516                    }
517                    out.push_str(&format!("{key} {}\n", c.get()));
518                }
519                Metric::Gauge(g) => {
520                    if declared.insert(base.to_owned()) {
521                        out.push_str(&format!("# HELP {base} {help}\n# TYPE {base} gauge\n"));
522                    }
523                    out.push_str(&format!("{key} {}\n", g.get()));
524                }
525                Metric::Histogram(h) => {
526                    if declared.insert(base.to_owned()) {
527                        out.push_str(&format!("# HELP {base} {help}\n# TYPE {base} histogram\n"));
528                    }
529                    // Snapshot the count ONCE before rendering either
530                    // the `+Inf` bucket or the `_count` line, AND
531                    // clamp the cumulative bucket sum against the
532                    // snapshot. Two invariants strict OpenMetrics
533                    // parsers enforce:
534                    //
535                    //   (a) `_count == cum(bucket{le="+Inf"})`
536                    //   (b) `bucket{le=X} <= bucket{le=Y}` for X < Y,
537                    //       and `bucket{le=X} <= _count` for all X
538                    //
539                    // `observe_us` writes `count.fetch_add` BEFORE
540                    // `bucket.fetch_add` (metrics.rs:176 → :181), so
541                    // a naive render that reads count first and
542                    // buckets after can see MORE bucket increments
543                    // than count increments — a concurrent observe
544                    // that completed both fetches between the
545                    // count snapshot and the bucket load. The
546                    // previous fix (R24 review) snapshotted count
547                    // once but didn't clamp; that closed invariant
548                    // (a) but re-opened invariant (b), because
549                    // `cum` could exceed `count_snapshot` on the
550                    // very next bucket iteration and get emitted
551                    // verbatim.
552                    //
553                    // Clamping cum against count_snapshot preserves
554                    // both invariants: bucket lines are monotone
555                    // non-decreasing (cum is monotone, `.min(K)` of
556                    // a monotone sequence is monotone), and every
557                    // bucket line is `<= count_snapshot` by
558                    // construction. Under a race, a bucket that
559                    // truly holds `N+1` observations may render as
560                    // `N` for one scrape; the missing observation
561                    // shows up on the next scrape. This is the same
562                    // "eventually consistent under concurrent
563                    // observation" contract every Prometheus client
564                    // library the ecosystem trusts uses.
565                    let count_snapshot = h.count();
566                    let mut cum = 0u64;
567                    for (i, b) in h.bounds_us.iter().enumerate() {
568                        cum += h.buckets.get(i).map_or(0, |x| x.load(Ordering::Relaxed));
569                        let clamped = cum.min(count_snapshot);
570                        let le = (*b as f64) / 1_000_000.0;
571                        out.push_str(&format!(
572                            "{base}_bucket{{{}le=\"{le}\"}} {clamped}\n",
573                            join_labels(labels)
574                        ));
575                    }
576                    out.push_str(&format!(
577                        "{base}_bucket{{{}le=\"+Inf\"}} {count_snapshot}\n",
578                        join_labels(labels),
579                    ));
580                    let sum_s = (h.sum_us.load(Ordering::Relaxed) as f64) / 1_000_000.0;
581                    out.push_str(&format!(
582                        "{base}_sum{labels_block} {sum_s}\n",
583                        labels_block = labels_suffix(labels)
584                    ));
585                    out.push_str(&format!(
586                        "{base}_count{labels_block} {count_snapshot}\n",
587                        labels_block = labels_suffix(labels)
588                    ));
589                }
590            }
591        }
592        out
593    }
594}
595
596/// Escape a HELP text per the Prometheus text exposition
597/// format spec. Backslash and line-feed are the only two chars the
598/// format reserves in HELP lines. A future counter/histogram
599/// registration whose HELP contained a newline would otherwise
600/// silently corrupt the scrape response — Prometheus would parse
601/// the remainder of the HELP text as metric samples and fail.
602fn escape_prom_help(help: &str) -> String {
603    let mut out = String::with_capacity(help.len());
604    for c in help.chars() {
605        match c {
606            '\\' => out.push_str("\\\\"),
607            '\n' => out.push_str("\\n"),
608            // Replace CR
609            // and every other C0 control (0x00–0x1F except LF
610            // which we just escaped) with a literal space.
611            // `validate_metric_key` already refuses these bytes
612            // in metric keys; the HELP-side didn't. NUL trips
613            // promtool lint / grafana-agent; ESC (0x1B) lets a
614            // caller who controls HELP text inject ANSI codes
615            // into operator terminals via `pvctl` piped
616            // `/metrics`. The substitution also covers DEL (0x7F) and the C1 range (0x80..=0x9F) —
617            // CSI (0x9B) is a valid single-byte ANSI escape
618            // prefix under 8-bit terminal emulation, so a HELP
619            // text carrying a `\u{9b}...` sequence renders the
620            // same way in an 8-bit-clean terminal as the C0
621            // ESC+`[` prefix we already scrub. Space is the
622            // same convention prometheus_client-python's
623            // `_ESCAPE_RE` uses.
624            c if (c as u32) < 0x20 || (c as u32) == 0x7f || (0x80..=0x9f).contains(&(c as u32)) => {
625                out.push(' ');
626            }
627            other => out.push(other),
628        }
629    }
630    out
631}
632
633/// Reject metric keys that would corrupt the Prometheus text exposition
634/// format. Legitimate keys carry label values like `{stage="identity"}` and
635/// must be allowed to contain double quotes and `=`, but a newline,
636/// carriage return, backslash, or NUL byte inside a key would split the
637/// scrape line and produce invalid text. These bytes arrive only from
638/// callers accidentally interpolating attacker-influenced strings into a
639/// series name; catch that at registration.
640#[allow(clippy::panic)]
641fn validate_metric_key(key: &str) {
642    // Split the key into base + labels first, then apply
643    // strict byte checks to the base name only. Label values are
644    // enclosed in double quotes by convention (`{stage="worker_queue"}`)
645    // so a global `"` ban would panic on every labelled counter
646    // registration (see the labelled-counter tests). The
647    // real hazard is a base-name that carries `"` / `\` / `\n` / `\r`
648    // / NUL — those would produce unbalanced quotes or split-line
649    // output that Prometheus rejects as invalid text exposition.
650    // Label values carry no render-time escaper: cross-line bytes
651    // (`\n` / `\r` / NUL) anywhere in the labels section are refused
652    // at registration by the loop below; this validator is the only
653    // guard, and its job covers both the base and the labels bytes.
654    let (base, labels) = split_key(key);
655    for byte in base.bytes() {
656        if matches!(byte, b'\n' | b'\r' | b'\\' | b'"' | 0x00) {
657            panic!(
658                "metric base name {base:?} contains a byte (0x{byte:02x}) that would corrupt \
659                 Prometheus text exposition; interpolating attacker-controlled strings \
660                 into metric names is unsafe",
661            );
662        }
663    }
664    // Also refuse the raw cross-line bytes anywhere in the labels
665    // section — Prometheus parses one metric per line, so any bare
666    // `\n`, `\r`, or NUL slips a synthetic line into the scrape.
667    // Backslash and double quote stay legal here because the quotes
668    // are structural to the `l="v"` convention and label sections are
669    // built from code-controlled constants (never attacker input);
670    // labels render verbatim — there is no render-time escaper for
671    // them (only HELP text gets `escape_prom_help`).
672    for byte in labels.bytes() {
673        if matches!(byte, b'\n' | b'\r' | 0x00) {
674            panic!(
675                "metric label section {labels:?} contains a byte (0x{byte:02x}) that would \
676                 corrupt Prometheus text exposition; interpolating attacker-controlled \
677                 strings into metric labels is unsafe",
678            );
679        }
680    }
681}
682
683/// Split `name{l="v"}` into (`name`, `l="v"`); no labels → (`key`, ``).
684fn split_key(key: &str) -> (&str, &str) {
685    match key.find('{') {
686        Some(i) => {
687            let base = key.get(..i).unwrap_or(key);
688            let labels = key.get(i + 1..key.len().saturating_sub(1)).unwrap_or("");
689            (base, labels)
690        }
691        None => (key, ""),
692    }
693}
694
695fn join_labels(labels: &str) -> String {
696    if labels.is_empty() {
697        String::new()
698    } else {
699        format!("{labels},")
700    }
701}
702
703fn labels_suffix(labels: &str) -> String {
704    if labels.is_empty() {
705        String::new()
706    } else {
707        format!("{{{labels}}}")
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn counter_roundtrip() {
717        let r = Registry::new();
718        let c = r.counter("pv_test_total", "test counter");
719        c.inc();
720        c.add(4);
721        assert_eq!(c.get(), 5);
722        let text = r.render();
723        assert!(text.contains("# TYPE pv_test_total counter"), "{text}");
724        assert!(text.contains("pv_test_total 5"), "{text}");
725    }
726
727    /// Mutation-run hardening: `validate_metric_key` replaced with a no-op
728    /// survived — nothing pinned that a base name carrying a scrape-
729    /// corrupting byte actually panics at registration.
730    #[test]
731    #[should_panic(expected = "corrupt")]
732    fn metric_key_with_newline_panics_at_registration() {
733        let r = Registry::new();
734        let _ = r.counter("pv_bad\nname_total", "bad");
735    }
736
737    /// Mutation-run hardening: `escape_prom_help`'s C0/DEL/C1 replacement
738    /// boundary was untested beyond `\n` — ESC (terminal injection via
739    /// piped /metrics), NUL (promtool lint), CR and CSI must all become
740    /// spaces, while printable ASCII from 0x20 up passes through.
741    #[test]
742    fn help_text_scrubs_all_control_bytes_to_spaces() {
743        let scrubbed = escape_prom_help("a\u{1b}[2Jb\u{0}c\rd\u{9b}e");
744        assert_eq!(scrubbed, "a [2Jb c d e");
745        assert_eq!(escape_prom_help(" printable ~"), " printable ~");
746        assert_eq!(escape_prom_help("line\nbreak\\slash"), "line\\nbreak\\\\slash");
747    }
748
749    /// Mutation-run hardening: the `_sum` line converts accumulated
750    /// microseconds to SECONDS via division; a `/`→`*` mutant survived
751    /// because no test pinned the rendered sum value.
752    #[test]
753    fn histogram_sum_renders_in_seconds() {
754        let r = Registry::new();
755        let h = r.histogram("pv_test_lat_seconds", "test latency");
756        h.observe_us(1_500_000);
757        h.observe_us(500_000);
758        let text = r.render();
759        assert!(
760            text.contains("pv_test_lat_seconds_sum 2\n"),
761            "2_000_000 us must render as 2 seconds: {text}"
762        );
763    }
764
765    #[test]
766    fn gauge_add_sub_set_and_render() {
767        let r = Registry::new();
768        let g = r.gauge("pv_test_gauge", "test gauge");
769        g.set(10);
770        g.inc();
771        g.add(3);
772        g.dec();
773        g.sub(2);
774        assert_eq!(g.get(), 11);
775        let text = r.render();
776        assert!(text.contains("# TYPE pv_test_gauge gauge"), "{text}");
777        assert!(text.contains("pv_test_gauge 11"), "{text}");
778    }
779
780    #[test]
781    fn gauge_saturates_at_zero_not_negative() {
782        let g = Gauge::default();
783        g.set(3);
784        g.sub(100);
785        assert_eq!(
786            g.get(),
787            0,
788            "gauge went negative — dashboards would be misleading and the review's data-plane counters would report bogus values"
789        );
790    }
791
792    #[test]
793    fn gauge_saturates_at_i64_max_not_wrap() {
794        let g = Gauge::default();
795        g.add(u64::MAX);
796        g.add(u64::MAX);
797        // Any positive number is a passing signal (proves no wrap);
798        // the specific ceiling is i64::MAX under the current impl.
799        assert!(g.get() > 0, "gauge wrapped through overflow");
800    }
801
802    #[test]
803    #[should_panic(expected = "already registered as `counter`")]
804    fn gauge_refuses_conflict_with_counter() {
805        let r = Registry::new();
806        let _c = r.counter("pv_conflict", "conflict");
807        let _g = r.gauge("pv_conflict", "conflict");
808    }
809
810    #[test]
811    #[should_panic(expected = "already registered as `gauge`")]
812    fn counter_refuses_conflict_with_gauge() {
813        let r = Registry::new();
814        let _g = r.gauge("pv_conflict2", "conflict");
815        let _c = r.counter("pv_conflict2", "conflict");
816    }
817
818    /// HELP text with an embedded newline must be escaped
819    /// so the Prometheus text-format scraper does not interpret the
820    /// second half as a metric sample. Backslash must also be
821    /// escaped per the spec.
822    #[test]
823    #[allow(clippy::expect_used)]
824    fn render_escapes_newlines_and_backslashes_in_help_text() {
825        let r = Registry::new();
826        r.counter("pv_dangerous_total", "line1\nline2\\path");
827        let text = r.render();
828        // A rendered HELP line must not contain the raw newline —
829        // any newline in the HELP must appear as literal `\n`.
830        let help_line = text
831            .lines()
832            .find(|line| line.starts_with("# HELP pv_dangerous_total"))
833            .expect("HELP line present");
834        assert!(help_line.contains(r"\n"), "help was not escaped: {help_line:?}");
835        assert!(
836            help_line.contains(r"\\"),
837            "backslash was not escaped: {help_line:?}"
838        );
839        // And the "line2" fragment must not be on its own line.
840        assert!(
841            !text.contains("\nline2\\path"),
842            "unescaped fragment leaked into scrape: {text}"
843        );
844    }
845
846    #[test]
847    fn counter_with_labels_renders_base_name_in_type() {
848        let r = Registry::new();
849        let c = r.counter("pv_drops_total{stage=\"publish\"}", "drops");
850        c.inc();
851        let text = r.render();
852        assert!(text.contains("# TYPE pv_drops_total counter"), "{text}");
853        assert!(text.contains("pv_drops_total{stage=\"publish\"} 1"), "{text}");
854    }
855
856    #[test]
857    fn labeled_family_is_declared_once() {
858        let r = Registry::new();
859        r.histogram("pv_stage_duration_seconds{stage=\"identity\"}", "stage latency");
860        r.histogram("pv_stage_duration_seconds{stage=\"quota\"}", "stage latency");
861        let text = r.render();
862        assert_eq!(
863            text.matches("# HELP pv_stage_duration_seconds ").count(),
864            1,
865            "{text}"
866        );
867        assert_eq!(
868            text.matches("# TYPE pv_stage_duration_seconds histogram").count(),
869            1,
870            "{text}"
871        );
872    }
873
874    #[test]
875    fn histogram_quantiles() {
876        let h = Histogram::new(DEFAULT_LATENCY_BOUNDS_US);
877        for _ in 0..99 {
878            h.observe_us(80); // ≤ 100µs bucket
879        }
880        h.observe_us(9_000); // ≤ 10ms bucket
881        assert_eq!(h.count(), 100);
882        assert_eq!(h.quantile_us(0.5), 100);
883        assert_eq!(h.quantile_us(0.99), 100);
884        assert_eq!(h.quantile_us(1.0), 10_000);
885    }
886
887    #[test]
888    fn histogram_renders_cumulative_buckets() {
889        let r = Registry::new();
890        let h = r.histogram("pv_lat", "latency");
891        h.observe_us(60);
892        h.observe_us(60);
893        h.observe_us(600);
894        let text = r.render();
895        // 50µs bucket: 0, 100µs bucket: 2, ..., 1ms bucket: 3
896        assert!(text.contains("pv_lat_bucket{le=\"0.0001\"} 2"), "{text}");
897        assert!(text.contains("pv_lat_bucket{le=\"0.001\"} 3"), "{text}");
898        assert!(text.contains("pv_lat_bucket{le=\"+Inf\"} 3"), "{text}");
899        assert!(text.contains("pv_lat_count 3"), "{text}");
900    }
901
902    /// Prometheus text format requires `_count == cum(bucket{le="+Inf"})`.
903    /// Pre-fix, the render read `h.count()` TWICE — once for the `+Inf`
904    /// bucket, once for the `_count` line — with `sum_us.load(...)` in
905    /// between. A concurrent `observe_us` completing between the two
906    /// loads left the scrape with `_count = N+1` and `+Inf = N`, which
907    /// strict OpenMetrics parsers reject and `histogram_quantile`
908    /// interprets as off-by-one quantile artifacts. Hammer both a
909    /// writer and a reader in parallel and assert the invariant on
910    /// EVERY scrape.
911    /// Prometheus text format enforces two invariants strict
912    /// OpenMetrics parsers reject on:
913    ///
914    ///   (a) `_count == cum(bucket{le="+Inf"})`
915    ///   (b) `bucket{le=X} <= bucket{le=Y}` for X < Y, and
916    ///       `bucket{le=X} <= _count` for all X
917    ///
918    /// R25 closed (a) by snapshotting count once, but LEFT (b) open
919    /// because `observe_us` writes count-then-bucket — a snapshot-
920    /// count-then-read-buckets order could see MORE bucket
921    /// increments than count increments (a concurrent observe that
922    /// completed both fetches between the count snapshot and the
923    /// bucket load). The current fix ALSO clamps cum against the
924    /// snapshot to preserve (b). Hammer a writer + reader in
925    /// parallel and assert BOTH invariants on EVERY scrape.
926    #[test]
927    #[allow(clippy::unwrap_used, clippy::expect_used)]
928    fn histogram_scrape_preserves_all_invariants_under_concurrent_observe() {
929        use std::sync::atomic::{AtomicBool, Ordering};
930        use std::sync::Arc;
931        use std::thread;
932
933        let r = Arc::new(Registry::new());
934        // Diverse bucket bounds so a busy writer spreads observations
935        // across several buckets — increases the chance of catching a
936        // mid-loop race between the bucket loop and the count snapshot.
937        let h = r.histogram_with_bounds(
938            "pv_race",
939            "concurrent scrape probe",
940            &[10, 100, 1_000, 10_000, 100_000],
941        );
942        let stop = Arc::new(AtomicBool::new(false));
943
944        let writer = {
945            let h = h.clone();
946            let stop = Arc::clone(&stop);
947            thread::spawn(move || {
948                // Rotate through values that hit different buckets.
949                let mut i: u64 = 0;
950                while !stop.load(Ordering::Relaxed) {
951                    let us = (i % 5 + 1) * 50; // 50, 100, 150, 200, 250
952                    h.observe_us(us);
953                    i = i.wrapping_add(1);
954                }
955            })
956        };
957
958        let start = std::time::Instant::now();
959        let mut scrapes = 0usize;
960        while start.elapsed() < std::time::Duration::from_millis(300) {
961            let text = r.render();
962            let mut buckets: Vec<(f64, u64)> = Vec::new();
963            let mut count: Option<u64> = None;
964            for line in text.lines() {
965                if line.starts_with("pv_race_bucket") {
966                    let le_start = line.find("le=\"").unwrap() + 4;
967                    let le_end = le_start + line[le_start..].find('"').unwrap();
968                    let le_str = &line[le_start..le_end];
969                    let val: u64 = line.rsplit(' ').next().and_then(|s| s.parse().ok()).unwrap();
970                    let le_f = if le_str == "+Inf" {
971                        f64::INFINITY
972                    } else {
973                        le_str.parse().unwrap()
974                    };
975                    buckets.push((le_f, val));
976                } else if line.starts_with("pv_race_count") {
977                    count = Some(line.rsplit(' ').next().and_then(|s| s.parse().ok()).unwrap());
978                }
979            }
980            let count = count.expect("_count line missing");
981            buckets.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
982            let mut prev = 0u64;
983            for (le, val) in &buckets {
984                // Invariant (b1): buckets monotone non-decreasing.
985                assert!(
986                    *val >= prev,
987                    "monotonicity violated at le={le}: {val} < prev {prev} on scrape {scrapes}"
988                );
989                // Invariant (b2): every bucket <= _count.
990                assert!(
991                    *val <= count,
992                    "bucket at le={le} = {val} > _count = {count} on scrape {scrapes}"
993                );
994                prev = *val;
995            }
996            // Invariant (a): +Inf bucket == _count.
997            let inf = buckets
998                .iter()
999                .find(|(le, _)| le.is_infinite())
1000                .expect("+Inf bucket missing")
1001                .1;
1002            assert_eq!(
1003                inf, count,
1004                "invariant (a) violated: +Inf = {inf} != _count = {count} on scrape {scrapes}"
1005            );
1006            scrapes = scrapes.saturating_add(1);
1007        }
1008        stop.store(true, Ordering::Relaxed);
1009        writer.join().unwrap();
1010        assert!(scrapes > 100, "should have taken > 100 scrapes; got {scrapes}");
1011    }
1012
1013    #[test]
1014    fn same_key_returns_same_metric() {
1015        let r = Registry::new();
1016        let a = r.counter("x_total", "x");
1017        let b = r.counter("x_total", "x");
1018        a.inc();
1019        assert_eq!(b.get(), 1);
1020    }
1021
1022    #[test]
1023    fn labeled_histogram_renders_labels_on_every_series_line() {
1024        // Catches `split_key`, `join_labels`, and `labels_suffix` stubs.
1025        // A labeled histogram must emit label pairs on _bucket, _sum, and
1026        // _count — the label prefix must land BEFORE the `le=` in buckets
1027        // and the entire `{labels}` block must land after _sum/_count.
1028        let r = Registry::new();
1029        let h = r.histogram("pv_lat{route=\"chat\"}", "lat");
1030        h.observe_us(60);
1031        let text = r.render();
1032        assert!(
1033            text.contains("pv_lat_bucket{route=\"chat\",le=\"0.0001\"} 1"),
1034            "join_labels lost the route label: {text}"
1035        );
1036        assert!(
1037            text.contains("pv_lat_sum{route=\"chat\"} "),
1038            "labels_suffix lost the route label on _sum: {text}"
1039        );
1040        assert!(
1041            text.contains("pv_lat_count{route=\"chat\"} 1"),
1042            "labels_suffix lost the route label on _count: {text}"
1043        );
1044    }
1045
1046    /// Vicious registry bug: `counter()` used to silently
1047    /// insert a fresh Counter over an existing Histogram at the same key,
1048    /// detaching the histogram from the registry and losing every prior
1049    /// observation. A metric name registered as one type must never be
1050    /// silently repurposed as the other — panic loudly instead.
1051    #[test]
1052    #[should_panic(expected = "kind conflict")]
1053    fn counter_over_existing_histogram_panics_instead_of_overwriting() {
1054        let r = Registry::new();
1055        r.histogram("pv_metric", "help");
1056        r.counter("pv_metric", "help");
1057    }
1058
1059    #[test]
1060    #[should_panic(expected = "kind conflict")]
1061    fn histogram_over_existing_counter_panics_instead_of_overwriting() {
1062        let r = Registry::new();
1063        r.counter("pv_metric", "help");
1064        r.histogram("pv_metric", "help");
1065    }
1066
1067    /// A histogram's text exposition generates `<base>_bucket`,
1068    /// `<base>_sum` and `<base>_count` series. Registering a counter
1069    /// under one of those names produced TWO samples named
1070    /// `<base>_count` under conflicting TYPE headers — Prometheus
1071    /// rejects the whole scrape as invalid exposition. Both
1072    /// registration orders must fail fast.
1073    #[test]
1074    #[should_panic(expected = "kind conflict")]
1075    fn counter_named_like_histogram_count_series_panics() {
1076        let r = Registry::new();
1077        r.histogram("pv_lat_seconds", "help");
1078        r.counter("pv_lat_seconds_count", "help");
1079    }
1080
1081    #[test]
1082    #[should_panic(expected = "kind conflict")]
1083    fn histogram_whose_generated_series_collide_with_counter_panics() {
1084        let r = Registry::new();
1085        r.counter("pv_lat_seconds_sum", "help");
1086        r.histogram("pv_lat_seconds", "help");
1087    }
1088
1089    /// The reservation must not break legitimate reuse: the same
1090    /// histogram re-registered (other label sets) and unrelated
1091    /// suffix-shaped names with no histogram sibling stay valid.
1092    #[test]
1093    fn histogram_reservation_permits_reregistration_and_unrelated_suffix_names() {
1094        let r = Registry::new();
1095        r.histogram("pv_lat_seconds{route=\"a\"}", "help");
1096        r.histogram("pv_lat_seconds{route=\"b\"}", "help");
1097        r.counter("pv_unrelated_count", "help");
1098        assert!(r.render().contains("pv_unrelated_count"));
1099    }
1100
1101    /// Cross-label type collision on the same base name: `foo{a="1"}`
1102    /// registered as counter, `foo{a="2"}` registered as histogram.
1103    /// Prometheus TYPE header would be ambiguous — reject at
1104    /// registration.
1105    #[test]
1106    #[should_panic(expected = "kind conflict")]
1107    fn different_labels_same_base_name_type_collision_panics() {
1108        let r = Registry::new();
1109        r.counter("pv_metric{shard=\"a\"}", "help");
1110        r.histogram("pv_metric{shard=\"b\"}", "help");
1111    }
1112
1113    /// Same-type re-registration must still work (idempotent) — the panic
1114    /// guard only fires on genuine type conflicts.
1115    #[test]
1116    fn same_type_re_registration_returns_existing_arc() {
1117        let r = Registry::new();
1118        let c1 = r.counter("pv_metric", "help");
1119        let c2 = r.counter("pv_metric", "help");
1120        c1.inc();
1121        assert_eq!(c2.get(), 1, "must return the same underlying counter");
1122
1123        let h1 = r.histogram("pv_other", "help");
1124        let h2 = r.histogram("pv_other", "help");
1125        h1.observe_us(50);
1126        assert_eq!(h2.count(), 1, "must return the same underlying histogram");
1127    }
1128
1129    /// HELP text escaper substitutes DEL (0x7F) and every
1130    /// C1 control (0x80..=0x9F) with a literal space. C0 controls
1131    /// were handled first; C1 was left through and CSI (0x9B) is a
1132    /// valid single-byte ANSI escape prefix under 8-bit terminal
1133    /// emulation. A future counter registration with operator-
1134    /// influenced HELP text (charter name / model name / SSE error
1135    /// snippet) that happened to include a C1 byte could otherwise
1136    /// inject terminal-escape sequences through `curl /metrics | less`.
1137    #[test]
1138    fn escape_prom_help_scrubs_del_and_c1_controls() {
1139        let dangerous = "help\u{7f}with\u{9b}csi\u{80}c1\u{9f}end";
1140        let out = escape_prom_help(dangerous);
1141        for c in ['\u{7f}', '\u{9b}', '\u{80}', '\u{9f}'] {
1142            assert!(!out.contains(c), "escape_prom_help left {c:?} in {out:?}");
1143        }
1144        // The safe chars are preserved.
1145        assert!(out.contains("help"));
1146        assert!(out.contains("csi"));
1147        assert!(out.contains("end"));
1148    }
1149}
1150
1151#[cfg(test)]
1152mod histogram_boundary_tests {
1153    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1154
1155    use super::*;
1156
1157    /// Mutation-run hardening: the overflow-bucket branch of
1158    /// `quantile_us` and the seconds conversion in `_sum` rendering were
1159    /// unpinned. A sample past the last bound must surface as u64::MAX
1160    /// (never silently under-report as the last bound), and the rendered
1161    /// sum must be the microsecond total divided by exactly 1e6.
1162    #[test]
1163    fn quantile_overflow_reports_max_and_sum_renders_in_seconds() {
1164        let r = Registry::new();
1165        let h = r.histogram_with_bounds("pv_overflow_probe", "probe", &[10, 100]);
1166        h.observe_us(1_000_000); // beyond the last bound: overflow bucket
1167        assert_eq!(h.quantile_us(0.99), u64::MAX);
1168        h.observe_us(2_000_000);
1169        let text = r.render();
1170        assert!(
1171            text.contains("pv_overflow_probe_sum 3"),
1172            "sum must be 3 seconds (3_000_000 us / 1e6), got:\n{text}"
1173        );
1174    }
1175}