notedthat_api_http/metrics.rs
1//! Request metrics for every surface on the unified listener (D69).
2//!
3//! One layer, applied to the merged router in `notedthat-server` after `WebDAV`
4//! and MCP are merged onto it. Applied inside [`crate::router::build_router`]'s
5//! own `ServiceBuilder` it would cover the API and the root routes only, since
6//! the other two surfaces are merged afterwards.
7//!
8//! # Why the route label is the pattern
9//!
10//! `route` is axum's [`MatchedPath`] — the *pattern*, `…/{kb_slug}/{*object_path}`,
11//! not the path that matched it. This is the whole of the label-safety story:
12//! the object route and every `WebDAV` route carry an object key in their path,
13//! and a key in a label would put customer data in an exposition that is
14//! retained for months by whoever scrapes it. A request that matched no route
15//! has no pattern, and its URI is attacker-chosen, so all of them share the one
16//! constant [`notedthat_core::metrics::ROUTE_UNMATCHED`].
17//!
18//! # Why the method label is an allow-list
19//!
20//! `http::Method` accepts arbitrary extension tokens, and this layer records
21//! before any `405`, so `req.method().as_str()` would let a client mint
22//! unbounded series by looping over invented verbs. Only the methods this
23//! server actually answers are labelled; anything else is `other`.
24//!
25//! # What the duration means
26//!
27//! Time to *response head*, not to last byte: `next.run` returns as soon as a
28//! handler produces a `Response`. That is deliberate. The events route returns
29//! instantly and then streams for hours, so body-completion timing would put
30//! hour-long observations in this histogram and pin the in-flight gauge at the
31//! subscriber count. A live stream's cost is `notedthat_events_subscribers`,
32//! and a large read's backend cost is `notedthat_storage_*`. Anyone "fixing"
33//! this to measure body completion would silently destroy both.
34
35use axum::extract::{MatchedPath, Request};
36use axum::http::{HeaderValue, Method};
37use axum::middleware::Next;
38use axum::response::Response;
39use notedthat_core::metrics::{ROUTE_UNMATCHED, label, name, surface};
40use std::time::Instant;
41
42use crate::router::helpers::SOURCE_HEADER;
43
44/// Mount points, matched against the route *pattern* rather than the path.
45const WEBDAV_PREFIX: &str = "/webdav";
46const MCP_ROUTE: &str = "/mcp";
47const SSE_PREFIX: &str = "/sse";
48const BROWSE_PREFIX: &str = "/browse";
49const API_PREFIX: &str = "/api/v1";
50
51/// The methods this server answers, as static labels.
52///
53/// The `WebDAV` verbs are here because `/webdav` answers them; anything outside
54/// this list shares one series, because the set of tokens a client may send is
55/// not bounded by anything we control.
56fn method_label(method: &Method) -> &'static str {
57 match *method {
58 Method::GET => "GET",
59 Method::HEAD => "HEAD",
60 Method::POST => "POST",
61 Method::PUT => "PUT",
62 Method::DELETE => "DELETE",
63 Method::PATCH => "PATCH",
64 Method::OPTIONS => "OPTIONS",
65 Method::TRACE => "TRACE",
66 Method::CONNECT => "CONNECT",
67 _ => match method.as_str() {
68 "PROPFIND" => "PROPFIND",
69 "PROPPATCH" => "PROPPATCH",
70 "MKCOL" => "MKCOL",
71 "COPY" => "COPY",
72 "MOVE" => "MOVE",
73 "LOCK" => "LOCK",
74 "UNLOCK" => "UNLOCK",
75 _ => "other",
76 },
77 }
78}
79
80/// Which surface served a request, from its matched pattern.
81///
82/// The `x-notedthat-source` header is consulted only under `/api/v1`, which is
83/// the only place the MCP server's loopback client sets it: an MCP tool call
84/// reaches the API over loopback, and without this it would be counted twice —
85/// once as `mcp` for the transport hop and again as `api` for the work. The
86/// header is informational and any client may send it, but it is matched
87/// byte-exactly against two constants, so the worst a client achieves is
88/// mis-filing its own request between `api` and `mcp`.
89fn surface_of(route: &str, source: Option<&HeaderValue>) -> &'static str {
90 if route == ROUTE_UNMATCHED {
91 return surface::ROOT;
92 }
93 if route.starts_with(WEBDAV_PREFIX) {
94 return surface::WEBDAV;
95 }
96 if route == MCP_ROUTE || route.starts_with(SSE_PREFIX) {
97 return surface::MCP;
98 }
99 if route.starts_with(BROWSE_PREFIX) {
100 return surface::BROWSE;
101 }
102 if route.starts_with(API_PREFIX) {
103 return if source.map(HeaderValue::as_bytes) == Some(b"mcp") {
104 surface::MCP
105 } else {
106 surface::API
107 };
108 }
109 surface::ROOT
110}
111
112/// One request's place in `notedthat_http_requests_in_flight`.
113///
114/// A guard rather than a matched pair of statements, because when a client
115/// disconnects hyper drops this middleware's future and nothing after
116/// `next.run` runs. Without the guard the gauge would ratchet upwards forever
117/// under exactly the conditions worth measuring.
118struct InFlight(&'static str);
119
120impl InFlight {
121 fn enter(surface: &'static str) -> Self {
122 metrics::gauge!(name::HTTP_IN_FLIGHT, label::SURFACE => surface).increment(1.0);
123 Self(surface)
124 }
125}
126
127impl Drop for InFlight {
128 fn drop(&mut self) {
129 metrics::gauge!(name::HTTP_IN_FLIGHT, label::SURFACE => self.0).decrement(1.0);
130 }
131}
132
133/// The `route` and `surface` labels for a request, as every HTTP series
134/// records them.
135pub(crate) fn route_and_surface(req: &Request) -> (String, &'static str) {
136 let route = req
137 .extensions()
138 .get::<MatchedPath>()
139 .map_or_else(|| ROUTE_UNMATCHED.to_string(), |m| m.as_str().to_string());
140 let surface = surface_of(&route, req.headers().get(SOURCE_HEADER));
141 (route, surface)
142}
143
144/// Count and time every request on every surface.
145pub async fn track_requests(req: Request, next: Next) -> Response {
146 let (route, surface) = route_and_surface(&req);
147 let method = method_label(req.method());
148
149 let _in_flight = InFlight::enter(surface);
150 let started = Instant::now();
151 let response = next.run(req).await;
152 let elapsed = started.elapsed().as_secs_f64();
153 let status = response.status().as_u16().to_string();
154
155 // `status` is on the counter but not the histogram: on the counter it is
156 // four cheap labels, while on the histogram it multiplies by the bucket
157 // count and the object route alone would be hundreds of series. Error
158 // *rates* come from the counter; the histogram answers "how slow is this
159 // route", which is the question a latency target is set from.
160 metrics::counter!(
161 name::HTTP_REQUESTS,
162 label::SURFACE => surface,
163 label::ROUTE => route.clone(),
164 label::METHOD => method,
165 label::STATUS => status,
166 )
167 .increment(1);
168 metrics::histogram!(
169 name::HTTP_REQUEST_DURATION,
170 label::SURFACE => surface,
171 label::ROUTE => route,
172 label::METHOD => method,
173 )
174 .record(elapsed);
175
176 response
177}
178
179#[cfg(test)]
180mod tests {
181 use super::{method_label, surface_of};
182 use axum::http::{HeaderValue, Method};
183 use notedthat_core::metrics::{ROUTE_UNMATCHED, surface};
184
185 #[test]
186 fn a_route_pattern_decides_the_surface() {
187 for (route, expected) in [
188 ("/webdav/{*path}", surface::WEBDAV),
189 ("/webdav", surface::WEBDAV),
190 ("/mcp", surface::MCP),
191 ("/sse/{*path}", surface::MCP),
192 ("/browse/{*path}", surface::BROWSE),
193 (
194 "/api/v1/knowledgebases/{kb_slug}/{*object_path}",
195 surface::API,
196 ),
197 ("/healthz", surface::ROOT),
198 ("/readyz", surface::ROOT),
199 ("/llms.txt", surface::ROOT),
200 (ROUTE_UNMATCHED, surface::ROOT),
201 ] {
202 assert_eq!(surface_of(route, None), expected, "for {route}");
203 }
204 }
205
206 /// Without this, one MCP tool call is counted twice: once on `/mcp` and
207 /// again on the loopback API call it makes to do the work.
208 #[test]
209 fn an_api_call_the_mcp_server_made_is_attributed_to_mcp() {
210 let mcp = HeaderValue::from_static("mcp");
211 assert_eq!(
212 surface_of("/api/v1/knowledgebases", Some(&mcp)),
213 surface::MCP
214 );
215 }
216
217 /// The header is informational and any client may send it. It must be able
218 /// to move a request between two constants and nothing more.
219 #[test]
220 fn the_source_header_cannot_move_another_surface_or_invent_one() {
221 let mcp = HeaderValue::from_static("mcp");
222 assert_eq!(surface_of("/webdav/{*path}", Some(&mcp)), surface::WEBDAV);
223 assert_eq!(surface_of("/browse", Some(&mcp)), surface::BROWSE);
224
225 let nonsense = HeaderValue::from_static("../../etc/passwd");
226 assert_eq!(
227 surface_of("/api/v1/knowledgebases", Some(&nonsense)),
228 surface::API,
229 "an unrecognised source is the default surface, never a label of its own"
230 );
231 }
232
233 /// `Method` accepts arbitrary extension tokens, and this label is recorded
234 /// before any 405, so an allow-list is the only thing bounding the series.
235 #[test]
236 fn an_invented_verb_shares_one_series() {
237 let invented =
238 Method::from_bytes(b"BREWCOFFEE").expect("an extension method is a valid token");
239 assert_eq!(method_label(&invented), "other");
240 assert_eq!(method_label(&Method::GET), "GET");
241 assert_eq!(
242 method_label(&Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid token")),
243 "PROPFIND"
244 );
245 }
246}