steid

@jamesgill /

Rust3.5 KBCode·Blame·History·Raw
1//! `/metrics`, for a control plane's scraper.
2//!
3//! The operator surface's second route, and the reasoning `operator.rs` gives applies
4//! whole: the bearer token is compared in constant time, and an instance that was never
5//! given one answers **404 rather than 401**, so the endpoint does not exist until it
6//! has been turned on. A control plane is not a user of the site and never gets a
7//! session here.
8//!
9//! **Nothing on this page names anything.** Not a handle, not a repository, not a path —
10//! every label is a closed constant, which is what keeps a scrape from becoming a
11//! directory of private repositories for whoever holds the token. See
12//! `infrastructure::metrics` for how that is enforced by the types rather than by
13//! discipline.
14//!
15//! **Zero subprocesses.** A scrape reads atomics and four in-memory lengths, so a
16//! scraper polling every fifteen seconds costs the same as polling every fifteen minutes.
17
18use topcoat::{
19 Result,
20 context::{Cx, app_context},
21 router::{
22 Body, Response, StatusCode,
23 error::not_found,
24 header::{CACHE_CONTROL, CONTENT_TYPE},
25 route,
26 },
27};
28
29use std::sync::Arc;
30
31use crate::infrastructure::{
32 cat_file::CatFilePool,
33 merge_cache::MergeCache,
34 metrics::{Gauges, Metrics},
35};
36
37use super::{
38 context::{highlights, server_error},
39 import::ImportQueue,
40 operator::operator_authorized,
41 rate_limit::metrics_throttled,
42};
43
44/// The exposition format's own content type. Version `0.0.4` is what every scraper still
45/// negotiates; it is the text format this module writes, not a version of Steid.
46const EXPOSITION: &str = "text/plain; version=0.0.4; charset=utf-8";
47
48/// Everything this process has counted.
49///
50/// Authorization first, then the limiter, and **only on the way out**: an authorized
51/// scrape never touches the limiter, so no amount of guessing from elsewhere can starve
52/// the collector. See [`METRICS_PER_CLIENT`](super::rate_limit) for why that is the
53/// opposite order from `/auth/login`.
54#[route(GET "/metrics")]
55async fn expose(cx: &Cx) -> Result<Response<Body>> {
56 if !operator_authorized(cx) {
57 if let Some(throttled) = metrics_throttled(cx) {
58 return Ok(throttled);
59 }
60
61 // Byte for byte what any unrouted path answers. A wrong token and a missing one
62 // are indistinguishable from each other and from a typo in the URL.
63 return Err(not_found().into());
64 }
65
66 let body = app_context::<Arc<Metrics>>(cx).render(&gauges(cx));
67
68 Response::builder()
69 .status(StatusCode::OK)
70 .header(CONTENT_TYPE, EXPOSITION)
71 // A scrape is a measurement of *now*. A proxy holding one for even a few seconds
72 // would flatten the very spikes somebody added this endpoint to see.
73 .header(CACHE_CONTROL, "no-store")
74 .body(Body::from(body))
75 .map_err(server_error)
76}
77
78/// The four numbers sampled from their real owners at scrape time.
79///
80/// Read here rather than mirrored into atomics on every write, so a gauge cannot drift
81/// from the structure it describes — see [`Gauges`].
82fn gauges(cx: &Cx) -> Gauges {
83 let (_, highlight_cache_bytes) = highlights(cx).stats();
84 let (_, merge_cache_bytes) = app_context::<Arc<MergeCache>>(cx).stats();
85
86 Gauges {
87 cat_file_pool: app_context::<Arc<CatFilePool>>(cx).live_repositories() as u64,
88 highlight_cache_bytes: highlight_cache_bytes as u64,
89 merge_cache_bytes: merge_cache_bytes as u64,
90 active_imports: app_context::<ImportQueue>(cx).active() as u64,
91 }
92}