steid

@jamesgill /

Rust32.6 KBCode·Blame·History·Raw
1//! What this process has done, in Prometheus text exposition.
2//!
3//! A struct of atomics rather than a metrics crate. The whole of what is exported is
4//! counters and four gauges over a fixed, closed set of labels, and a crate would bring
5//! a registry, a label-cardinality model and an encoder to do what a hundred lines of
6//! addition and one `writeln!` loop already do. The exposition format is a stable
7//! contract with a scraper, so it is written here and pinned by tests rather than
8//! delegated.
9//!
10//! **Every label is closed.** A series is a slot in a fixed array, indexed by an enum or
11//! by a position in a `const` table, so nothing a request carries can invent one.
12//! Cardinality is a property of the type rather than of what somebody asked for — which
13//! is the failure mode a metrics endpoint has: a path or a handle used as a label turns
14//! a scrape into an unbounded map an anonymous caller controls.
15//!
16//! **Counting must never change behaviour.** Every recording method is one relaxed
17//! atomic add. `Ordering::Relaxed` is right for exactly this: the counters are never
18//! read to make a decision, only rendered, and a scrape that sees one increment a
19//! microsecond late is indistinguishable from a scrape taken a microsecond earlier.
20//!
21//! One `Metrics` per process, reached through [`metrics`] the way
22//! [`highlight::cache`](super::highlight::cache) is reached — because the call sites are
23//! spread across adapters that have no `Cx` (the `cat-file` pool, the caches, the git
24//! run helpers) and threading one through all of them would be a parameter on every
25//! function to say what one process-wide value already says. `main` puts the same `Arc`
26//! in app context, which is where the route reads it from.
27
28use std::{
29 fmt::Write as _,
30 sync::{
31 Arc, OnceLock,
32 atomic::{AtomicU64, Ordering},
33 },
34 time::Instant,
35};
36
37/// The one set of counters this process has.
38///
39/// `main` clones the `Arc` into app context so `/metrics` can read it without knowing
40/// about this function; everything that *writes* calls this directly.
41pub fn metrics() -> &'static Arc<Metrics> {
42 static METRICS: OnceLock<Arc<Metrics>> = OnceLock::new();
43 METRICS.get_or_init(|| Arc::new(Metrics::new()))
44}
45
46/// What a request was for, as far as a scrape needs to know.
47///
48/// Six classes rather than the path, for the reason the module doc gives: a path is
49/// unbounded and caller-controlled. These are also the six that behave differently under
50/// load — a page is a database read, a git request is a subprocess streaming a pack, an
51/// archive packs the repository — so a spike in one of them says something a total does
52/// not.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum RouteClass {
55 Page,
56 Api,
57 Git,
58 Archive,
59 Feed,
60 Other,
61}
62
63impl RouteClass {
64 /// Every class, in the order they are rendered.
65 const ALL: [Self; 6] = [
66 Self::Page,
67 Self::Api,
68 Self::Git,
69 Self::Archive,
70 Self::Feed,
71 Self::Other,
72 ];
73
74 #[must_use]
75 pub fn as_str(self) -> &'static str {
76 match self {
77 Self::Page => "page",
78 Self::Api => "api",
79 Self::Git => "git",
80 Self::Archive => "archive",
81 Self::Feed => "feed",
82 Self::Other => "other",
83 }
84 }
85
86 fn index(self) -> usize {
87 match self {
88 Self::Page => 0,
89 Self::Api => 1,
90 Self::Git => 2,
91 Self::Archive => 3,
92 Self::Feed => 4,
93 Self::Other => 5,
94 }
95 }
96}
97
98/// Which class a request path falls into.
99///
100/// Matched on shape rather than against the router's table, which is not introspectable
101/// — so this is a second statement of the same URL layout and has to be kept beside it.
102/// The tests below are what makes that survivable: each arm names a real route.
103///
104/// The order matters. `…/repos/{name}/archive/…` and `…/commits.atom` are both under a
105/// repository, and an unordered match would call them pages.
106#[must_use]
107pub fn classify(path: &str) -> RouteClass {
108 if path == "/metrics" || path == "/healthz" || path == "/robots.txt" {
109 return RouteClass::Other;
110 }
111
112 if path.starts_with("/api/") {
113 return RouteClass::Api;
114 }
115
116 // The assets Topcoat serves are not the application, and a page's stylesheet
117 // arriving would otherwise double every page count.
118 if path.starts_with("/assets/") || path.starts_with("/_topcoat") {
119 return RouteClass::Other;
120 }
121
122 if path.ends_with("/info/refs")
123 || path.ends_with("/git-upload-pack")
124 || path.ends_with("/git-receive-pack")
125 {
126 return RouteClass::Git;
127 }
128
129 if path.contains("/archive/") {
130 return RouteClass::Archive;
131 }
132
133 if path.ends_with(".atom") {
134 return RouteClass::Feed;
135 }
136
137 RouteClass::Page
138}
139
140/// How many status classes are tracked: `1xx` through `5xx`.
141const STATUS_CLASSES: usize = 5;
142
143/// `1xx` … `5xx`, and `None` for a status outside that range — which HTTP has none of,
144/// but a `u16` does.
145fn status_index(status: u16) -> Option<usize> {
146 (100..600)
147 .contains(&status)
148 .then(|| status as usize / 100 - 1)
149}
150
151/// The label a status class is rendered under.
152fn status_label(index: usize) -> &'static str {
153 ["1xx", "2xx", "3xx", "4xx", "5xx"][index]
154}
155
156/// The git subcommands Steid runs, and the only labels
157/// [`git_processes_total`](Metrics::record_git) can produce.
158///
159/// A closed table rather than the subcommand as it was spelled: every one of these is an
160/// internal constant today, so an open label would be safe *today* — and a metric whose
161/// cardinality depends on nobody ever passing a caller-supplied argument in first
162/// position is a trap set for later. Anything not here is counted as `other`, which is
163/// the signal to add it.
164const GIT_COMMANDS: [&str; 21] = [
165 "archive",
166 "blame",
167 "cat-file",
168 "clone",
169 "commit-tree",
170 "config",
171 "diff-tree",
172 "fetch",
173 "for-each-ref",
174 "grep",
175 "http-backend",
176 "init",
177 "log",
178 "merge-base",
179 "merge-tree",
180 "remote",
181 "rev-list",
182 "rev-parse",
183 "symbolic-ref",
184 "update-ref",
185 "version",
186];
187
188/// The label for anything [`GIT_COMMANDS`] does not name.
189const GIT_OTHER: &str = "other";
190
191/// Which limiter refused a request.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum Limiter {
194 /// `/auth/login` and `/auth/setup`.
195 Auth,
196 /// Token authentication on the git transport.
197 GitAuth,
198 /// Archive downloads.
199 Archive,
200 /// Unauthorized `/metrics` attempts.
201 Metrics,
202 /// Code searches.
203 Search,
204}
205
206impl Limiter {
207 const ALL: [Self; 5] = [
208 Self::Auth,
209 Self::GitAuth,
210 Self::Archive,
211 Self::Metrics,
212 Self::Search,
213 ];
214
215 fn as_str(self) -> &'static str {
216 match self {
217 Self::Auth => "auth",
218 Self::GitAuth => "git_auth",
219 Self::Archive => "archive",
220 Self::Metrics => "metrics",
221 Self::Search => "search",
222 }
223 }
224
225 fn index(self) -> usize {
226 match self {
227 Self::Auth => 0,
228 Self::GitAuth => 1,
229 Self::Archive => 2,
230 Self::Metrics => 3,
231 Self::Search => 4,
232 }
233 }
234}
235
236/// How one webhook delivery attempt ended.
237///
238/// Three outcomes rather than the status code, because the three are what an operator
239/// acts on differently: a receiver answering 500 is the receiver's problem, a receiver
240/// that cannot be reached at all is usually DNS or a firewall, and Steid refusing to dial
241/// a private address is a configuration mistake on this side. The status itself is on the
242/// delivery row, which is where somebody debugging one hook looks.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum DeliveryOutcome {
245 /// A 2xx.
246 Delivered,
247 /// A response arrived and it was not a 2xx.
248 Rejected,
249 /// No status at all: DNS, a refused connection, TLS, the timeout, or an address the
250 /// adapter would not dial.
251 Unreachable,
252}
253
254impl DeliveryOutcome {
255 const ALL: [Self; 3] = [Self::Delivered, Self::Rejected, Self::Unreachable];
256
257 /// Reads an outcome off the status a [`WebhookOutcome`](crate::application::port::WebhookOutcome)
258 /// carried.
259 #[must_use]
260 pub fn of(status: Option<u16>) -> Self {
261 match status {
262 Some(status) if (200..300).contains(&status) => Self::Delivered,
263 Some(_) => Self::Rejected,
264 None => Self::Unreachable,
265 }
266 }
267
268 fn as_str(self) -> &'static str {
269 match self {
270 Self::Delivered => "delivered",
271 Self::Rejected => "rejected",
272 Self::Unreachable => "unreachable",
273 }
274 }
275
276 fn index(self) -> usize {
277 match self {
278 Self::Delivered => 0,
279 Self::Rejected => 1,
280 Self::Unreachable => 2,
281 }
282 }
283}
284
285/// The four numbers that are read from somewhere else at scrape time.
286///
287/// A gauge is the current value of something that already exists — how many `cat-file`
288/// processes are pooled, how many bytes a cache holds — and mirroring those into atomics
289/// on every write would be a second copy of the truth, free to drift from the first. So
290/// the route samples the real owners and hands them here.
291#[derive(Debug, Clone, Copy, Default)]
292pub struct Gauges {
293 /// Repositories with a live `cat-file` slot in the pool.
294 pub cat_file_pool: u64,
295 /// Bytes the highlight cache is accounting for.
296 pub highlight_cache_bytes: u64,
297 /// Bytes the merge cache is accounting for.
298 pub merge_cache_bytes: u64,
299 /// Imports currently holding a permit — cloning, rather than queued.
300 pub active_imports: u64,
301}
302
303/// Everything this process counts.
304///
305/// Fields are private and every one of them is written through a method, so a call site
306/// cannot reach past the closed-label rule the module doc states.
307#[derive(Debug)]
308pub struct Metrics {
309 started: Instant,
310 http: Vec<AtomicU64>,
311 git: Vec<AtomicU64>,
312 cat_file_requests: AtomicU64,
313 cat_file_respawns: AtomicU64,
314 highlight_hits: AtomicU64,
315 highlight_misses: AtomicU64,
316 merge_hits: AtomicU64,
317 merge_misses: AtomicU64,
318 rate_limited: Vec<AtomicU64>,
319 deliveries: Vec<AtomicU64>,
320 imports_started: AtomicU64,
321 imports_finished: AtomicU64,
322 imports_failed: AtomicU64,
323}
324
325impl Default for Metrics {
326 fn default() -> Self {
327 Self::new()
328 }
329}
330
331fn zeroed(len: usize) -> Vec<AtomicU64> {
332 (0..len).map(|_| AtomicU64::new(0)).collect()
333}
334
335impl Metrics {
336 #[must_use]
337 pub fn new() -> Self {
338 Self {
339 started: Instant::now(),
340 http: zeroed(RouteClass::ALL.len() * STATUS_CLASSES),
341 git: zeroed(GIT_COMMANDS.len() + 1),
342 cat_file_requests: AtomicU64::new(0),
343 cat_file_respawns: AtomicU64::new(0),
344 highlight_hits: AtomicU64::new(0),
345 highlight_misses: AtomicU64::new(0),
346 merge_hits: AtomicU64::new(0),
347 merge_misses: AtomicU64::new(0),
348 rate_limited: zeroed(Limiter::ALL.len()),
349 deliveries: zeroed(DeliveryOutcome::ALL.len()),
350 imports_started: AtomicU64::new(0),
351 imports_finished: AtomicU64::new(0),
352 imports_failed: AtomicU64::new(0),
353 }
354 }
355
356 /// Records one served request. Called from the security-headers layer, which is the
357 /// only thing that sees every response — see its module doc.
358 pub fn record_request(&self, path: &str, status: u16) {
359 let Some(status) = status_index(status) else {
360 return;
361 };
362
363 let class = classify(path);
364 self.http[class.index() * STATUS_CLASSES + status].fetch_add(1, Ordering::Relaxed);
365 }
366
367 /// Records one `git` process. Called from the shared run helpers, so no caller can
368 /// forget it.
369 pub fn record_git(&self, subcommand: &str) {
370 let index = GIT_COMMANDS
371 .iter()
372 .position(|known| *known == subcommand)
373 .unwrap_or(GIT_COMMANDS.len());
374
375 self.git[index].fetch_add(1, Ordering::Relaxed);
376 }
377
378 /// Records one exchange with a pooled `git cat-file`.
379 pub fn record_cat_file_request(&self) {
380 self.cat_file_requests.fetch_add(1, Ordering::Relaxed);
381 }
382
383 /// Records a pooled `cat-file` that had died and was started again.
384 pub fn record_cat_file_respawn(&self) {
385 self.cat_file_respawns.fetch_add(1, Ordering::Relaxed);
386 }
387
388 pub fn record_highlight_hit(&self) {
389 self.highlight_hits.fetch_add(1, Ordering::Relaxed);
390 }
391
392 pub fn record_highlight_miss(&self) {
393 self.highlight_misses.fetch_add(1, Ordering::Relaxed);
394 }
395
396 pub fn record_merge_hit(&self) {
397 self.merge_hits.fetch_add(1, Ordering::Relaxed);
398 }
399
400 pub fn record_merge_miss(&self) {
401 self.merge_misses.fetch_add(1, Ordering::Relaxed);
402 }
403
404 /// Records a request one of the limiters refused.
405 pub fn record_rate_limited(&self, limiter: Limiter) {
406 self.rate_limited[limiter.index()].fetch_add(1, Ordering::Relaxed);
407 }
408
409 /// Records one webhook delivery attempt — a retry is an attempt like any other.
410 pub fn record_delivery(&self, outcome: DeliveryOutcome) {
411 self.deliveries[outcome.index()].fetch_add(1, Ordering::Relaxed);
412 }
413
414 pub fn record_import_started(&self) {
415 self.imports_started.fetch_add(1, Ordering::Relaxed);
416 }
417
418 pub fn record_import_finished(&self) {
419 self.imports_finished.fetch_add(1, Ordering::Relaxed);
420 }
421
422 pub fn record_import_failed(&self) {
423 self.imports_failed.fetch_add(1, Ordering::Relaxed);
424 }
425
426 /// Seconds since the process started counting.
427 #[must_use]
428 pub fn uptime_seconds(&self) -> f64 {
429 self.started.elapsed().as_secs_f64()
430 }
431
432 /// The current value of one rendered series, for a test elsewhere in the crate that
433 /// needs to watch a counter move through real work.
434 ///
435 /// Reads it back out of the exposition rather than off the field, so a test that
436 /// passes is evidence the number reached a scraper — the counters are process-wide
437 /// and tests run concurrently, which is why every caller asserts a *rise* rather than
438 /// an absolute value.
439 #[cfg(test)]
440 pub(crate) fn value_of(&self, series: &str) -> u64 {
441 self.render(&Gauges::default())
442 .lines()
443 .find_map(|line| line.strip_prefix(series))
444 .and_then(|rest| rest.strip_prefix(' '))
445 .unwrap_or_else(|| panic!("{series} is not in the exposition"))
446 .parse()
447 .expect("a counter renders as an integer")
448 }
449
450 /// The whole exposition, in the text format a Prometheus scraper reads.
451 ///
452 /// **Every series of every family is emitted, including the zeroes.** A counter that
453 /// appears only once it is non-zero looks to `rate()` like a counter reset, and the
454 /// first error of the day would arrive as a spike from nowhere. The whole exposition
455 /// is under a hundred lines, so there is nothing to save by hiding the zeroes.
456 #[must_use]
457 pub fn render(&self, gauges: &Gauges) -> String {
458 let mut out = String::with_capacity(4096);
459
460 gauge(
461 &mut out,
462 "steid_uptime_seconds",
463 "Seconds since this process started serving.",
464 &[],
465 self.uptime_seconds(),
466 );
467
468 family(
469 &mut out,
470 "steid_http_requests_total",
471 "counter",
472 "Requests served, by route class and status class.",
473 );
474 for class in RouteClass::ALL {
475 for status in 0..STATUS_CLASSES {
476 sample(
477 &mut out,
478 "steid_http_requests_total",
479 &[("class", class.as_str()), ("status", status_label(status))],
480 self.http[class.index() * STATUS_CLASSES + status].load(Ordering::Relaxed),
481 );
482 }
483 }
484
485 family(
486 &mut out,
487 "steid_git_processes_total",
488 "counter",
489 "git processes spawned, by subcommand.",
490 );
491 for (index, command) in GIT_COMMANDS
492 .iter()
493 .copied()
494 .chain(std::iter::once(GIT_OTHER))
495 .enumerate()
496 {
497 sample(
498 &mut out,
499 "steid_git_processes_total",
500 &[("command", command)],
501 self.git[index].load(Ordering::Relaxed),
502 );
503 }
504
505 counter(
506 &mut out,
507 "steid_cat_file_requests_total",
508 "Exchanges with a pooled git cat-file process.",
509 self.cat_file_requests.load(Ordering::Relaxed),
510 );
511 counter(
512 &mut out,
513 "steid_cat_file_respawns_total",
514 "Pooled git cat-file processes found dead and started again.",
515 self.cat_file_respawns.load(Ordering::Relaxed),
516 );
517 gauge(
518 &mut out,
519 "steid_cat_file_pool_size",
520 "Repositories holding a live cat-file slot in the pool.",
521 &[],
522 gauges.cat_file_pool as f64,
523 );
524
525 counter(
526 &mut out,
527 "steid_highlight_cache_hits_total",
528 "Highlight cache lookups answered from the cache.",
529 self.highlight_hits.load(Ordering::Relaxed),
530 );
531 counter(
532 &mut out,
533 "steid_highlight_cache_misses_total",
534 "Highlight cache lookups that had to be computed.",
535 self.highlight_misses.load(Ordering::Relaxed),
536 );
537 gauge(
538 &mut out,
539 "steid_cache_bytes",
540 "Bytes each in-process cache is accounting for.",
541 &[("cache", "highlight")],
542 gauges.highlight_cache_bytes as f64,
543 );
544 sample(
545 &mut out,
546 "steid_cache_bytes",
547 &[("cache", "merge")],
548 gauges.merge_cache_bytes,
549 );
550
551 counter(
552 &mut out,
553 "steid_merge_cache_hits_total",
554 "Merge preview lookups answered from the cache.",
555 self.merge_hits.load(Ordering::Relaxed),
556 );
557 counter(
558 &mut out,
559 "steid_merge_cache_misses_total",
560 "Merge preview lookups that had to run git merge-tree.",
561 self.merge_misses.load(Ordering::Relaxed),
562 );
563
564 family(
565 &mut out,
566 "steid_rate_limit_rejections_total",
567 "counter",
568 "Requests refused, by limiter.",
569 );
570 for limiter in Limiter::ALL {
571 sample(
572 &mut out,
573 "steid_rate_limit_rejections_total",
574 &[("limiter", limiter.as_str())],
575 self.rate_limited[limiter.index()].load(Ordering::Relaxed),
576 );
577 }
578
579 family(
580 &mut out,
581 "steid_webhook_deliveries_total",
582 "counter",
583 "Webhook delivery attempts, by outcome. A retry counts as an attempt.",
584 );
585 for outcome in DeliveryOutcome::ALL {
586 sample(
587 &mut out,
588 "steid_webhook_deliveries_total",
589 &[("outcome", outcome.as_str())],
590 self.deliveries[outcome.index()].load(Ordering::Relaxed),
591 );
592 }
593
594 counter(
595 &mut out,
596 "steid_imports_started_total",
597 "Repository imports that began cloning.",
598 self.imports_started.load(Ordering::Relaxed),
599 );
600 counter(
601 &mut out,
602 "steid_imports_finished_total",
603 "Repository imports whose clone succeeded.",
604 self.imports_finished.load(Ordering::Relaxed),
605 );
606 counter(
607 &mut out,
608 "steid_imports_failed_total",
609 "Repository imports whose clone failed.",
610 self.imports_failed.load(Ordering::Relaxed),
611 );
612 gauge(
613 &mut out,
614 "steid_active_imports",
615 "Imports currently cloning, as opposed to queued.",
616 &[],
617 gauges.active_imports as f64,
618 );
619
620 out
621 }
622}
623
624/// The two comment lines that open a family.
625fn family(out: &mut String, name: &str, kind: &str, help: &str) {
626 let _ = writeln!(out, "# HELP {name} {}", escape_help(help));
627 let _ = writeln!(out, "# TYPE {name} {kind}");
628}
629
630/// One sample line. `labels` may be empty, in which case no braces are written — a
631/// scraper accepts `name{} 1`, but the canonical form has no braces and the tests read
632/// better against it.
633fn sample(out: &mut String, name: &str, labels: &[(&str, &str)], value: u64) {
634 write_sample(out, name, labels, &value.to_string());
635}
636
637/// A whole single-sample counter family.
638fn counter(out: &mut String, name: &str, help: &str, value: u64) {
639 family(out, name, "counter", help);
640 sample(out, name, &[], value);
641}
642
643/// A whole single-sample gauge family.
644///
645/// The value is a `f64` because uptime is fractional; an integral gauge renders without
646/// a decimal point, which is what `{:?}` on a whole `f64` does not do — hence the
647/// formatting split in [`float`].
648fn gauge(out: &mut String, name: &str, help: &str, labels: &[(&str, &str)], value: f64) {
649 family(out, name, "gauge", help);
650 write_sample(out, name, labels, &float(value));
651}
652
653fn write_sample(out: &mut String, name: &str, labels: &[(&str, &str)], value: &str) {
654 out.push_str(name);
655
656 if !labels.is_empty() {
657 out.push('{');
658 for (index, (label, label_value)) in labels.iter().enumerate() {
659 if index > 0 {
660 out.push(',');
661 }
662 let _ = write!(out, "{label}=\"{}\"", escape_label_value(label_value));
663 }
664 out.push('}');
665 }
666
667 let _ = writeln!(out, " {value}");
668}
669
670/// A gauge value, rendered the way the exposition format wants it.
671///
672/// A whole number renders without a fractional part, because `steid_active_imports 2.0`
673/// is legal but reads as though something were being rounded. Anything else keeps three
674/// decimal places — millisecond resolution on an uptime, and nothing so long that a
675/// scrape line wraps.
676fn float(value: f64) -> String {
677 if value.is_finite() && value.fract() == 0.0 && value.abs() < 1e15 {
678 format!("{value:.0}")
679 } else {
680 format!("{value:.3}")
681 }
682}
683
684/// Escapes a label value: backslash, double quote, and newline.
685///
686/// Every label value Steid emits is a `const` string with none of these in it, so this
687/// never fires today. It exists because the alternative — trusting that no future label
688/// ever carries a quote — is a rule enforced by nobody, and one unescaped quote does not
689/// corrupt a line, it corrupts the *rest of the scrape*: the parser sees the value end
690/// early and rejects everything after it.
691fn escape_label_value(value: &str) -> String {
692 let mut escaped = String::with_capacity(value.len());
693
694 for character in value.chars() {
695 match character {
696 '\\' => escaped.push_str("\\\\"),
697 '"' => escaped.push_str("\\\""),
698 '\n' => escaped.push_str("\\n"),
699 other => escaped.push(other),
700 }
701 }
702
703 escaped
704}
705
706/// Escapes help text: backslash and newline only.
707///
708/// **Not the double quote** — help text is not quoted, and escaping one there would put
709/// a literal backslash in front of it in every scraper's UI. The two escapers are
710/// deliberately separate for exactly this: the format has two rules, and one function
711/// doing both would have to be wrong about one of them.
712fn escape_help(help: &str) -> String {
713 let mut escaped = String::with_capacity(help.len());
714
715 for character in help.chars() {
716 match character {
717 '\\' => escaped.push_str("\\\\"),
718 '\n' => escaped.push_str("\\n"),
719 other => escaped.push(other),
720 }
721 }
722
723 escaped
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 fn rendered(metrics: &Metrics) -> String {
731 metrics.render(&Gauges::default())
732 }
733
734 #[test]
735 fn a_path_is_classified_by_what_it_costs_the_server() {
736 assert_eq!(classify("/jamesgill"), RouteClass::Page);
737 assert_eq!(
738 classify("/jamesgill/repos/steid/tree/main"),
739 RouteClass::Page
740 );
741 assert_eq!(classify("/api/users/jamesgill/repos"), RouteClass::Api);
742 assert_eq!(
743 classify("/jamesgill/repos/steid/info/refs"),
744 RouteClass::Git
745 );
746 assert_eq!(
747 classify("/jamesgill/repos/steid/git-upload-pack"),
748 RouteClass::Git
749 );
750 assert_eq!(
751 classify("/jamesgill/repos/steid/git-receive-pack"),
752 RouteClass::Git
753 );
754 assert_eq!(
755 classify("/jamesgill/repos/steid/archive/main.tar.gz"),
756 RouteClass::Archive
757 );
758 assert_eq!(
759 classify("/jamesgill/repos/steid/commits.atom"),
760 RouteClass::Feed
761 );
762 assert_eq!(classify("/jamesgill/feed.atom"), RouteClass::Feed);
763 assert_eq!(classify("/healthz"), RouteClass::Other);
764 assert_eq!(classify("/metrics"), RouteClass::Other);
765 assert_eq!(classify("/robots.txt"), RouteClass::Other);
766 assert_eq!(classify("/assets/styles-abc123.css"), RouteClass::Other);
767 }
768
769 #[test]
770 fn a_request_lands_in_one_series_and_leaves_the_rest_alone() {
771 let metrics = Metrics::new();
772
773 metrics.record_request("/jamesgill/repos/steid/tree/main", 200);
774 metrics.record_request("/jamesgill/repos/steid/info/refs", 401);
775
776 let text = rendered(&metrics);
777 assert!(
778 text.contains("steid_http_requests_total{class=\"page\",status=\"2xx\"} 1"),
779 "{text}"
780 );
781 assert!(
782 text.contains("steid_http_requests_total{class=\"git\",status=\"4xx\"} 1"),
783 "{text}"
784 );
785 assert!(
786 text.contains("steid_http_requests_total{class=\"page\",status=\"5xx\"} 0"),
787 "{text}"
788 );
789 }
790
791 #[test]
792 fn every_series_is_present_before_anything_has_happened() {
793 // A counter that appears only once it is non-zero reads to `rate()` as a reset.
794 let text = rendered(&Metrics::new());
795
796 for class in RouteClass::ALL {
797 for status in ["1xx", "2xx", "3xx", "4xx", "5xx"] {
798 assert!(
799 text.contains(&format!(
800 "steid_http_requests_total{{class=\"{}\",status=\"{status}\"}} 0",
801 class.as_str()
802 )),
803 "{text}"
804 );
805 }
806 }
807
808 for command in GIT_COMMANDS.iter().chain(std::iter::once(&GIT_OTHER)) {
809 assert!(
810 text.contains(&format!(
811 "steid_git_processes_total{{command=\"{command}\"}} 0"
812 )),
813 "{command} is missing from the exposition"
814 );
815 }
816 }
817
818 #[test]
819 fn every_family_declares_its_help_and_type_exactly_once() {
820 let text = rendered(&Metrics::new());
821
822 let names: Vec<&str> = text
823 .lines()
824 .filter_map(|line| line.strip_prefix("# TYPE "))
825 .map(|line| line.split(' ').next().expect("a type line names a metric"))
826 .collect();
827
828 for name in &names {
829 assert_eq!(
830 text.matches(&format!("# TYPE {name} ")).count(),
831 1,
832 "{name} declares its type more than once"
833 );
834 assert_eq!(
835 text.matches(&format!("# HELP {name} ")).count(),
836 1,
837 "{name} declares its help more than once"
838 );
839 }
840
841 // Every sample line belongs to a family that was declared before it.
842 for line in text.lines().filter(|line| !line.starts_with('#')) {
843 let name = line
844 .split(['{', ' '])
845 .next()
846 .expect("a sample line names a metric");
847 assert!(names.contains(&name), "{name} has no # TYPE line");
848 }
849 }
850
851 #[test]
852 fn a_counter_is_a_whole_number_and_a_gauge_may_not_be() {
853 let metrics = Metrics::new();
854 metrics.record_git("log");
855
856 let text = metrics.render(&Gauges {
857 cat_file_pool: 3,
858 highlight_cache_bytes: 4096,
859 merge_cache_bytes: 128,
860 active_imports: 1,
861 });
862
863 assert!(
864 text.contains("steid_git_processes_total{command=\"log\"} 1"),
865 "{text}"
866 );
867 assert!(text.contains("steid_cat_file_pool_size 3"), "{text}");
868 assert!(
869 text.contains("steid_cache_bytes{cache=\"highlight\"} 4096"),
870 "{text}"
871 );
872 assert!(
873 text.contains("steid_cache_bytes{cache=\"merge\"} 128"),
874 "{text}"
875 );
876 assert!(text.contains("steid_active_imports 1"), "{text}");
877
878 let uptime = text
879 .lines()
880 .find_map(|line| line.strip_prefix("steid_uptime_seconds "))
881 .expect("uptime is exposed");
882 assert!(
883 uptime.parse::<f64>().is_ok(),
884 "uptime should parse as a number, got {uptime}"
885 );
886 }
887
888 #[test]
889 fn an_unknown_subcommand_is_counted_as_other_rather_than_inventing_a_series() {
890 let metrics = Metrics::new();
891 metrics.record_git("frobnicate");
892
893 assert!(
894 rendered(&metrics).contains("steid_git_processes_total{command=\"other\"} 1"),
895 "an unknown subcommand must not become its own label"
896 );
897 }
898
899 #[test]
900 fn a_status_outside_http_is_counted_nowhere_rather_than_panicking() {
901 let metrics = Metrics::new();
902 metrics.record_request("/", 0);
903 metrics.record_request("/", 999);
904
905 let text = rendered(&metrics);
906 assert!(
907 text.contains("steid_http_requests_total{class=\"page\",status=\"2xx\"} 0"),
908 "{text}"
909 );
910 }
911
912 #[test]
913 fn a_label_value_escapes_the_three_characters_that_would_end_the_line_early() {
914 assert_eq!(escape_label_value("plain"), "plain");
915 assert_eq!(escape_label_value(r#"a"b"#), r#"a\"b"#);
916 assert_eq!(escape_label_value(r"a\b"), r"a\\b");
917 assert_eq!(escape_label_value("a\nb"), r"a\nb");
918 assert_eq!(escape_label_value("\\\"\n"), r#"\\\"\n"#);
919 }
920
921 #[test]
922 fn help_escapes_the_backslash_and_the_newline_but_never_the_quote() {
923 // Help text is not quoted, so escaping a quote would show a literal backslash in
924 // every scraper's UI.
925 assert_eq!(escape_help(r#"say "hi""#), r#"say "hi""#);
926 assert_eq!(escape_help(r"a\b"), r"a\\b");
927 assert_eq!(escape_help("one\ntwo"), r"one\ntwo");
928 }
929
930 #[test]
931 fn an_escaped_label_value_survives_into_the_rendered_line() {
932 let mut out = String::new();
933 write_sample(&mut out, "x", &[("l", "a\"b")], "1");
934
935 assert_eq!(out, "x{l=\"a\\\"b\"} 1\n");
936 }
937
938 #[test]
939 fn a_delivery_outcome_reads_the_status_the_way_the_receiver_meant_it() {
940 assert_eq!(DeliveryOutcome::of(Some(200)), DeliveryOutcome::Delivered);
941 assert_eq!(DeliveryOutcome::of(Some(204)), DeliveryOutcome::Delivered);
942 assert_eq!(DeliveryOutcome::of(Some(301)), DeliveryOutcome::Rejected);
943 assert_eq!(DeliveryOutcome::of(Some(500)), DeliveryOutcome::Rejected);
944 assert_eq!(DeliveryOutcome::of(None), DeliveryOutcome::Unreachable);
945 }
946
947 #[test]
948 fn each_counter_moves_only_its_own_series() {
949 let metrics = Metrics::new();
950
951 metrics.record_cat_file_request();
952 metrics.record_cat_file_respawn();
953 metrics.record_highlight_hit();
954 metrics.record_merge_miss();
955 metrics.record_rate_limited(Limiter::Metrics);
956 metrics.record_delivery(DeliveryOutcome::Unreachable);
957 metrics.record_import_started();
958 metrics.record_import_failed();
959
960 let text = rendered(&metrics);
961 for expected in [
962 "steid_cat_file_requests_total 1",
963 "steid_cat_file_respawns_total 1",
964 "steid_highlight_cache_hits_total 1",
965 "steid_highlight_cache_misses_total 0",
966 "steid_merge_cache_hits_total 0",
967 "steid_merge_cache_misses_total 1",
968 "steid_rate_limit_rejections_total{limiter=\"metrics\"} 1",
969 "steid_rate_limit_rejections_total{limiter=\"auth\"} 0",
970 "steid_webhook_deliveries_total{outcome=\"unreachable\"} 1",
971 "steid_webhook_deliveries_total{outcome=\"delivered\"} 0",
972 "steid_imports_started_total 1",
973 "steid_imports_finished_total 0",
974 "steid_imports_failed_total 1",
975 ] {
976 assert!(text.contains(expected), "missing {expected} in\n{text}");
977 }
978 }
979
980 #[test]
981 fn one_metrics_is_shared_by_everything_that_asks_for_it() {
982 assert!(Arc::ptr_eq(metrics(), metrics()));
983 }
984}