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