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}