Skip to main content

max / makenotwork

28.5 KB · 729 lines History Blame Raw
1 //! Prometheus metrics: HTTP request tracking, error counters, DB pool gauges.
2 //!
3 //! Call [`init`] once at startup to install the Prometheus recorder.
4 //! The HTTP middleware in [`metrics_middleware`] records per-request metrics.
5 //!
6 //! There is no `/metrics` scrape endpoint: PoM is the monitoring story (see
7 //! `lib.rs`). The recorder stays because the in-process admin dashboard renders
8 //! from the same handle, and [`render`] is the exposition-format handler for
9 //! whenever a scrape endpoint is wanted.
10
11 use axum::{
12 extract::{MatchedPath, Request, State},
13 middleware::Next,
14 response::{IntoResponse, Response},
15 };
16 use metrics::{counter, gauge, histogram};
17 use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
18 use std::time::Instant;
19
20 /// Install the global Prometheus recorder. Returns the handle used to render
21 /// the scrape output. Call once from `main`.
22 pub fn init() -> PrometheusHandle {
23 PrometheusBuilder::new()
24 .install_recorder()
25 .expect("failed to install Prometheus recorder")
26 }
27
28 /// Render all collected metrics in Prometheus exposition format.
29 #[allow(
30 clippy::unused_async,
31 reason = "axum handler: a sync fn returning impl IntoResponse does not implement the Handler trait"
32 )]
33 pub async fn render(State(handle): State<PrometheusHandle>) -> impl IntoResponse {
34 handle.render()
35 }
36
37 /// Axum middleware that sets `Cache-Control` headers based on route path.
38 ///
39 /// Public content pages get CDN-friendly caching (Cloudflare caches for 60s,
40 /// browsers always revalidate). Dashboard, API, and auth routes get no caching.
41 ///
42 /// A public route is only stamped shared-cacheable when the response is truly
43 /// viewer-independent: a response that sets a cookie (per-client session state)
44 /// or was rendered for an authenticated viewer (owner controls + a per-session
45 /// CSRF token baked into `base.html`) is downgraded to `private`. Without this a
46 /// shared CDN under a "cache everything" rule could serve one user's
47 /// personalized `/u /p /i /c` page, including their CSRF token, to another.
48 /// Reading the session *after* the handler
49 /// runs reuses the already-loaded session, so this adds no extra DB round-trip.
50 pub async fn cache_control_middleware(request: Request, next: Next) -> Response {
51 use axum::http::header::{CACHE_CONTROL, SET_COOKIE};
52
53 let path = request.uri().path().to_string();
54 let public = is_public_page(&path);
55 // Clone the session handle before consuming the request; only read it for
56 // public routes (where the caching decision depends on the viewer).
57 let session = if public {
58 request
59 .extensions()
60 .get::<tower_sessions::Session>()
61 .cloned()
62 } else {
63 None
64 };
65
66 let mut response = next.run(request).await;
67
68 // Don't override if a handler already set Cache-Control
69 if response.headers().contains_key(CACHE_CONTROL) {
70 return response;
71 }
72
73 let authed = match session {
74 Some(ref s) => crate::auth::session_user(s).await.is_some(),
75 None => false,
76 };
77 let sets_cookie = response.headers().contains_key(SET_COOKIE);
78 let value = cache_control_value(&path, public, sets_cookie, authed);
79
80 response
81 .headers_mut()
82 .insert(CACHE_CONTROL, axum::http::HeaderValue::from_static(value));
83
84 // Add API version header to all /api/* responses
85 if path.starts_with("/api/") {
86 response.headers_mut().insert(
87 axum::http::HeaderName::from_static("mnw-version"),
88 axum::http::HeaderValue::from_static("2026-04-23"),
89 );
90 }
91
92 response
93 }
94
95 /// The `Cache-Control` value for a response given its route class and whether
96 /// the response turned out to be viewer-independent.
97 ///
98 /// `public` is `is_public_page(path)` (passed in so the caller computes it once).
99 /// A public route is only shared-cacheable when it neither sets a cookie nor was
100 /// served to an authenticated viewer; otherwise it is per-client and must stay
101 /// `private`.
102 fn cache_control_value(path: &str, public: bool, sets_cookie: bool, authed: bool) -> &'static str {
103 if public {
104 if sets_cookie || authed {
105 // Per-client (cookie) or personalized (authed), never shared-cache.
106 "private, no-cache"
107 } else {
108 // CDN caches 60s, browser always revalidates, stale served while refreshing.
109 "public, max-age=0, s-maxage=60, stale-while-revalidate=300"
110 }
111 } else if path.starts_with("/api/")
112 || path.starts_with("/stripe/")
113 || path.starts_with("/postmark/")
114 {
115 "no-store"
116 } else {
117 // Dashboard, admin, auth, private, always revalidate
118 "private, no-cache"
119 }
120 }
121
122 /// Returns true for public content pages that benefit from CDN caching.
123 fn is_public_page(path: &str) -> bool {
124 // `/economics` is a viewer-independent marketing page (aggregate counts only);
125 // it was falling through to `private, no-cache` and recomputing on every hit
126 // (ultra-fuzz Run 12 doubledown Perf 1b). Let the CDN cache it like the others.
127 matches!(path, "/" | "/discover" | "/pricing" | "/source" | "/economics")
128 || path.starts_with("/p/")
129 || path.starts_with("/i/")
130 || path.starts_with("/u/")
131 || path.starts_with("/c/")
132 || path.starts_with("/docs")
133 || path.starts_with("/discover/")
134 || path.starts_with("/source/")
135 // `/feed/{user_id}` is a per-URL, HMAC-signed, viewer-independent RSS feed,
136 // safe to CDN-cache. Bare `/feed` is the AuthUser, per-viewer follows page:
137 // it must NOT be stamped `public`, or a shared CDN could serve one user's
138 // private feed to another (ultra-fuzz Run 12 doubledown cross-cutting).
139 || path.starts_with("/feed/")
140 }
141
142 /// Axum middleware that records HTTP request metrics.
143 ///
144 /// For every request, records:
145 /// - `http_requests_total` counter with labels: method, path, status
146 /// - `http_request_duration_seconds` histogram with labels: method, path, status
147 ///
148 /// `path` uses the matched route pattern (e.g. `/api/items/:id`) to keep
149 /// cardinality bounded. Unmatched routes are grouped under `<unmatched>`.
150 pub async fn metrics_middleware(request: Request, next: Next) -> Response {
151 let method = request.method().to_string();
152 let path = request
153 .extensions()
154 .get::<MatchedPath>()
155 .map_or_else(|| "<unmatched>".to_string(), |p| p.as_str().to_string());
156
157 let start = Instant::now();
158 let response = next.run(request).await;
159 let duration = start.elapsed().as_secs_f64();
160
161 let status = status_class(response.status().as_u16());
162
163 let labels = [
164 ("method", method),
165 ("path", path),
166 ("status", status.to_string()),
167 ];
168
169 counter!("http_requests_total", &labels).increment(1);
170 histogram!("http_request_duration_seconds", &labels).record(duration);
171
172 response
173 }
174
175 /// Collapse HTTP status codes into classes to keep label cardinality low.
176 fn status_class(code: u16) -> &'static str {
177 match code {
178 200..=299 => "2xx",
179 300..=399 => "3xx",
180 400..=499 => "4xx",
181 500..=599 => "5xx",
182 _ => "other",
183 }
184 }
185
186 /// Record current DB pool statistics as gauges. Call periodically from the
187 /// health monitor or a dedicated task.
188 pub fn record_db_pool_stats(pool: &sqlx::PgPool) {
189 let size = pool.size() as f64;
190 let idle = pool.num_idle() as f64;
191 gauge!("db_pool_connections_max").set(size);
192 gauge!("db_pool_connections_idle").set(idle);
193 gauge!("db_pool_connections_active").set(size - idle);
194 }
195
196 /// Record server-wide Postgres saturation as Prometheus gauges. Sibling of
197 /// the local-pool gauges, this looks at the SHARED Postgres (MNW + MT +
198 /// ad hoc clients) so a dashboard can see the global ceiling, not just our
199 /// pool's share. Cheap query (single row from `pg_stat_activity`).
200 ///
201 /// Returns `(active_backends, max_connections)` so the caller can also fire
202 /// the existing WAM alert when utilization climbs past the threshold.
203 #[tracing::instrument(skip_all)]
204 pub async fn record_pg_stat_activity(pool: &sqlx::PgPool) -> Option<(i64, i64)> {
205 let row: Result<(i64, i64), _> = sqlx::query_as(
206 "SELECT \
207 (SELECT count(*) FROM pg_stat_activity \
208 WHERE state IS NOT NULL AND backend_type = 'client backend')::bigint, \
209 current_setting('max_connections')::bigint",
210 )
211 .fetch_one(pool)
212 .await;
213
214 match row {
215 Ok((active, max_conn)) if max_conn > 0 => {
216 gauge!("pg_stat_activity_active_backends").set(active as f64);
217 gauge!("pg_stat_activity_max_connections").set(max_conn as f64);
218 gauge!("pg_stat_activity_utilization_ratio").set(active as f64 / max_conn as f64);
219 Some((active, max_conn))
220 }
221 Ok(_) => None,
222 Err(e) => {
223 tracing::debug!(error = ?e, "pg_stat_activity gauge update failed");
224 None
225 }
226 }
227 }
228
229 /// Aggregated storage fill metrics across all paying creators.
230 ///
231 /// Emits three gauges so the dashboard can compute fill ratio without
232 /// joining queries client-side:
233 /// - `creator_storage_used_bytes_total`, sum of `users.storage_used_bytes`
234 /// for users with an active creator subscription.
235 /// - `creator_storage_cap_bytes_total`, sum of the corresponding tier caps.
236 /// - `creator_storage_fill_ratio`, used / cap.
237 ///
238 /// Pricing economics assume ~20% fill; 60%+ is 3× projection and warrants
239 /// re-pricing. This gauge is the canonical input for that threshold.
240 #[tracing::instrument(skip_all)]
241 pub async fn record_storage_fill_stats(pool: &sqlx::PgPool) {
242 // Tier caps come from the installed TierPrices global (loaded from
243 // assumptions.toml at startup). Binding them here means a single edit
244 // to the toml propagates to both `CreatorTier::max_storage_bytes` and
245 // the metrics query, no chance of SQL/Rust drift like the previous
246 // inline VALUES table had.
247 use crate::db::CreatorTier;
248 let tp = crate::tier_prices::TierPrices::global();
249 let basic = tp.max_storage_bytes_for(CreatorTier::Basic);
250 let small_files = tp.max_storage_bytes_for(CreatorTier::SmallFiles);
251 let big_files = tp.max_storage_bytes_for(CreatorTier::BigFiles);
252 let everything = tp.max_storage_bytes_for(CreatorTier::Everything);
253
254 let row: Result<(i64, i64), _> = sqlx::query_as(
255 r"
256 WITH tier_caps(tier, cap_bytes) AS (
257 VALUES
258 ('basic'::text, $1::bigint),
259 ('small_files'::text, $2::bigint),
260 ('big_files'::text, $3::bigint),
261 ('everything'::text, $4::bigint)
262 )
263 SELECT
264 COALESCE(SUM(u.storage_used_bytes), 0)::bigint AS used,
265 COALESCE(SUM(tc.cap_bytes), 0)::bigint AS cap
266 FROM users u
267 JOIN creator_subscriptions cs
268 ON cs.user_id = u.id AND cs.status = 'active'
269 JOIN tier_caps tc ON tc.tier = cs.tier
270 ",
271 )
272 .bind(basic)
273 .bind(small_files)
274 .bind(big_files)
275 .bind(everything)
276 .fetch_one(pool)
277 .await;
278
279 match row {
280 Ok((used, cap)) => {
281 gauge!("creator_storage_used_bytes_total").set(used as f64);
282 gauge!("creator_storage_cap_bytes_total").set(cap as f64);
283 let ratio = if cap > 0 {
284 used as f64 / cap as f64
285 } else {
286 0.0
287 };
288 gauge!("creator_storage_fill_ratio").set(ratio);
289 }
290 Err(e) => {
291 tracing::debug!(error = ?e, "storage fill stats query failed");
292 }
293 }
294 }
295
296 /// Emit the current `domain_cache` size as a gauge so dashboards can track
297 /// cache growth + correlate with `caddy_ask_total{outcome="cache_hit"}`.
298 pub fn record_domain_cache_size(size: usize) {
299 gauge!("domain_cache_entries").set(size as f64);
300 }
301
302 /// Count of live custom pages (profiles and project pages with non-empty
303 /// source). Cheap two-count query; refreshed on the same cadence as storage
304 /// fill. Lets us watch custom-page adoption without scraping the DB by hand.
305 #[tracing::instrument(skip_all)]
306 pub async fn record_custom_pages_stats(pool: &sqlx::PgPool) {
307 let users: Result<(i64,), _> =
308 sqlx::query_as("SELECT count(*) FROM users WHERE custom_html <> '' OR custom_css <> ''")
309 .fetch_one(pool)
310 .await;
311 let projects: Result<(i64,), _> =
312 sqlx::query_as("SELECT count(*) FROM projects WHERE custom_html <> '' OR custom_css <> ''")
313 .fetch_one(pool)
314 .await;
315 match (users, projects) {
316 (Ok((u,)), Ok((p,))) => {
317 gauge!("custom_pages_active", "kind" => "profile").set(u as f64);
318 gauge!("custom_pages_active", "kind" => "project").set(p as f64);
319 }
320 _ => tracing::debug!("custom-pages stats query failed"),
321 }
322 }
323
324 /// Increment the sanitizer-rejection counter for one stripped reference, keyed
325 /// by kind (e.g. `external_url`, `blocked_at_rule`). Called at save time so the
326 /// counts reflect what creators actually publish, not per-keystroke previews.
327 pub fn record_sanitizer_rejection(kind: &'static str) {
328 counter!("custom_pages_sanitizer_rejections_total", "kind" => kind).increment(1);
329 }
330
331 /// Increment the ClamAV fail-open counter: a trusted upload accepted Clean while
332 /// clamd was transiently down and the health probe had not yet observed it (the
333 /// one reduced-AV-coverage acceptance path). Scraped at `/metrics` and paired
334 /// with a WAM ticket so the window is observable rather than log-only.
335 pub fn record_clamav_degraded_hold() {
336 counter!("clamav_degraded_hold_total").increment(1);
337 }
338
339 /// Record the terminal verdict of a completed file scan, keyed by verdict
340 /// (`clean`, `quarantined`, `held_for_review`, `error`). Quarantine and hold
341 /// rates and scanner error rates are security-critical signals, so they are
342 /// graphable rather than only the degraded-hold counter.
343 pub fn record_scan_verdict(verdict: &'static str) {
344 counter!("scan_verdicts_total", "verdict" => verdict).increment(1);
345 }
346
347 /// Record wall-clock duration of a completed file scan (seconds), so a slow
348 /// scanner (clamd backpressure, large-archive walk) is graphable rather than
349 /// only inferable from queue depth.
350 pub fn record_scan_duration(seconds: f64) {
351 histogram!("scan_duration_seconds").record(seconds);
352 }
353
354 /// Sample the scan-job queue depth (pending / running / stuck) as gauges.
355 /// Called from the periodic monitor loop alongside the other stat collectors so
356 /// a stalled worker or a scan backlog is observable at `/metrics`, not just via
357 /// the unauthenticated health JSON.
358 pub async fn record_scan_queue_stats(pool: &sqlx::PgPool) {
359 let pending = crate::db::scan_jobs::queued_count(pool).await.unwrap_or(0);
360 let running = crate::db::scan_jobs::running_count(pool).await.unwrap_or(0);
361 let stuck = crate::db::scan_jobs::stuck_count(pool, 300)
362 .await
363 .unwrap_or(0);
364 gauge!("scan_queue_jobs", "state" => "pending").set(pending as f64);
365 gauge!("scan_queue_jobs", "state" => "running").set(running as f64);
366 gauge!("scan_queue_jobs", "state" => "stuck").set(stuck as f64);
367 }
368
369 /// Axum middleware that implements idempotency keys for POST endpoints.
370 ///
371 /// If the request includes an `Idempotency-Key` header and the user is
372 /// authenticated, checks for a cached response. If found, returns the cached
373 /// response immediately. Otherwise, runs the handler and caches the result.
374 ///
375 /// Skips silently if no `Idempotency-Key` header is present or if the user
376 /// is not authenticated (no session user).
377 pub async fn idempotency_middleware(
378 State(state): State<crate::AppState>,
379 request: Request,
380 next: Next,
381 ) -> Response {
382 use axum::http::StatusCode;
383
384 // Only applies to POST/PUT methods
385 if !matches!(
386 *request.method(),
387 axum::http::Method::POST | axum::http::Method::PUT
388 ) {
389 return next.run(request).await;
390 }
391
392 // Extract idempotency key from header
393 let idem_key = request
394 .headers()
395 .get("idempotency-key")
396 .and_then(|v| v.to_str().ok())
397 .map(std::string::ToString::to_string);
398
399 let idem_key = match idem_key {
400 Some(k) if !k.is_empty() && k.len() <= 256 => k,
401 _ => return next.run(request).await, // No key, proceed normally
402 };
403
404 // Extract user ID from session (must be authenticated)
405 let session = request
406 .extensions()
407 .get::<tower_sessions::Session>()
408 .cloned();
409 let user_id: Option<crate::db::UserId> = if let Some(ref session) = session {
410 session
411 .get::<crate::auth::SessionUser>("user")
412 .await
413 .ok()
414 .flatten()
415 .map(|u| u.id)
416 } else {
417 None
418 };
419
420 let Some(user_id) = user_id else {
421 // Not authenticated, skip
422 return next.run(request).await;
423 };
424
425 let method = request.method().to_string();
426 let path = request.uri().path().to_string();
427
428 // In-memory negative cache: every POST/PUT with an Idempotency-Key was
429 // previously taking a pool conn for `get_cached_response` even when the
430 // key had never been seen, measurable cost on a hot POST that already
431 // makes 2-5 DB queries. Keys that recently returned None are tracked
432 // here so the SELECT is skipped. Single-process correctness: the DB
433 // cache table is written ONLY by this middleware's success path, which
434 // also evicts the key from this map.
435 type NegKey = (String, crate::db::UserId);
436 static NEG_CACHE: std::sync::OnceLock<dashmap::DashMap<NegKey, std::time::Instant>> =
437 std::sync::OnceLock::new();
438 const NEG_TTL_SECS: u64 = 60;
439 let neg_cache = NEG_CACHE.get_or_init(dashmap::DashMap::new);
440 let neg_key = (idem_key.clone(), user_id);
441 let recently_negative = neg_cache
442 .get(&neg_key)
443 .is_some_and(|e| e.elapsed().as_secs() < NEG_TTL_SECS);
444
445 // Periodic GC to keep the map bounded: sweep every ~1k misses, OR whenever
446 // the map exceeds a hard size cap. The tick alone let a flood of unique
447 // keys (each missing exactly once) sit up to 1024-deep between sweeps; the
448 // size trigger bounds memory under that pattern regardless of tick phase
449 // (ultra-fuzz Run #1 Perf LOW).
450 const NEG_CACHE_MAX_ENTRIES: usize = 8192;
451 static GC_TICK: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
452 let tick = GC_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
453 if tick.is_multiple_of(1024) || neg_cache.len() > NEG_CACHE_MAX_ENTRIES {
454 neg_cache.retain(|_, t| t.elapsed().as_secs() < NEG_TTL_SECS);
455 }
456
457 // Check for cached response (scoped to key + user + method + path)
458 if !recently_negative
459 && let Ok(Some(cached)) = crate::db::idempotency::get_cached_response(
460 &state.db, &idem_key, user_id, &method, &path,
461 )
462 .await
463 {
464 tracing::debug!(key = %idem_key, "returning cached idempotency response");
465 let status = StatusCode::from_u16(cached.status_code as u16).unwrap_or(StatusCode::OK);
466 return (status, cached.response_body).into_response();
467 }
468
469 // Cache miss (or skipped via negative cache), record the miss timestamp
470 // so subsequent calls in the next NEG_TTL_SECS can skip the DB SELECT.
471 neg_cache.insert(neg_key.clone(), std::time::Instant::now());
472
473 // Run the handler
474 let response = next.run(request).await;
475
476 // Cache the response (fire-and-forget, don't block the response on DB write)
477 let status_code = response.status().as_u16();
478
479 // Only cache successful responses (2xx/3xx) to avoid caching transient errors
480 if status_code < 400 {
481 // Only cache when content-length is present AND <= 1MB. We must decide
482 // BEFORE consuming the body, otherwise a chunked / unknown-length response
483 // that exceeds the cap would be silently truncated to empty, a correctness
484 // landmine, since the status + headers would still claim success.
485 let content_length = response
486 .headers()
487 .get(axum::http::header::CONTENT_LENGTH)
488 .and_then(|v| v.to_str().ok())
489 .and_then(|v| v.parse::<usize>().ok());
490 let Some(len) = content_length else {
491 tracing::debug!(
492 key = %idem_key, method = %method, path = %path,
493 "no content-length on response; skipping idempotency cache (body left intact)"
494 );
495 return response;
496 };
497 if len > 1024 * 1024 {
498 tracing::info!(
499 key = %idem_key, method = %method, path = %path, len,
500 "response body exceeds 1MB; skipping idempotency cache"
501 );
502 return response;
503 }
504
505 // Extract body bytes to cache. Content-length confirms <= 1MB, so this
506 // should not exceed the limit; if it does, that's a header/body mismatch
507 // and we surface 500 rather than silently dropping the body.
508 let (parts, body) = response.into_parts();
509 let body_bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
510 Ok(b) => b,
511 Err(e) => {
512 tracing::error!(
513 key = %idem_key, method = %method, path = %path, error = ?e,
514 "response body exceeded 1MB despite content-length <= 1MB; failing closed"
515 );
516 return axum::response::Response::builder()
517 .status(StatusCode::INTERNAL_SERVER_ERROR)
518 .body(axum::body::Body::from("internal error"))
519 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
520 }
521 };
522 // Only cache UTF-8 responses, skip binary content to avoid corruption
523 if let Ok(body_str) = std::str::from_utf8(&body_bytes) {
524 let body_owned = body_str.to_owned();
525 let db = state.db.clone();
526 let key = idem_key.clone();
527 // Evict the negative-cache entry now that this key has a real
528 // cached response, subsequent requests should hit the DB and get
529 // the cached body, not keep skipping via the stale negative.
530 neg_cache.remove(&neg_key);
531 // Route through the bounded background pool, not a raw `tokio::spawn`:
532 // this runs on every successful idempotent write, and an ungated
533 // spawn would let a write burst accumulate tasks each grabbing a DB
534 // pool connection, the exact contention `background.rs` exists to
535 // bound (ultra-fuzz Run #1 Perf MODERATE).
536 state.bg.spawn("idempotency_store", async move {
537 if let Err(e) = crate::db::idempotency::store_response(
538 &db,
539 &key,
540 user_id,
541 &method,
542 &path,
543 status_code,
544 &body_owned,
545 )
546 .await
547 {
548 tracing::warn!(key = %key, error = ?e, "failed to store idempotency key");
549 }
550 });
551 }
552
553 axum::response::Response::from_parts(parts, axum::body::Body::from(body_bytes))
554 } else {
555 response
556 }
557 }
558
559 /// Snapshot of current metrics for the admin dashboard.
560 pub struct MetricsSnapshot {
561 pub total_requests: u64,
562 pub total_5xx: u64,
563 pub total_errors: u64,
564 /// (method, path, status, count) sorted by count descending
565 pub top_routes: Vec<(String, String, String, u64)>,
566 /// (kind, count) sorted by count descending
567 pub error_breakdown: Vec<(String, u64)>,
568 }
569
570 /// Parse the Prometheus text output into a structured snapshot.
571 /// This avoids adding a dependency on the prometheus data model, just
572 /// string-parses the exposition format we produce ourselves.
573 pub fn snapshot(handle: &PrometheusHandle) -> MetricsSnapshot {
574 let text = handle.render();
575 let mut total_requests: u64 = 0;
576 let mut total_5xx: u64 = 0;
577 let mut routes: Vec<(String, String, String, u64)> = Vec::new();
578 let mut errors: Vec<(String, u64)> = Vec::new();
579
580 for line in text.lines() {
581 if line.starts_with('#') || line.is_empty() {
582 continue;
583 }
584
585 if let Some(rest) = line.strip_prefix("http_requests_total{") {
586 if let Some((labels, value)) = rest.rsplit_once("} ") {
587 let count: u64 = value.parse().unwrap_or(0);
588 let method = extract_label(labels, "method");
589 let path = extract_label(labels, "path");
590 let status = extract_label(labels, "status");
591 total_requests += count;
592 if status == "5xx" {
593 total_5xx += count;
594 }
595 routes.push((method, path, status, count));
596 }
597 } else if let Some(rest) = line.strip_prefix("http_errors_total{")
598 && let Some((labels, value)) = rest.rsplit_once("} ")
599 {
600 let count: u64 = value.parse().unwrap_or(0);
601 let kind = extract_label(labels, "kind");
602 errors.push((kind, count));
603 }
604 }
605
606 routes.sort_by_key(|r| std::cmp::Reverse(r.3));
607 routes.truncate(20);
608 errors.sort_by_key(|e| std::cmp::Reverse(e.1));
609
610 let total_errors = errors.iter().map(|(_, c)| c).sum();
611
612 MetricsSnapshot {
613 total_requests,
614 total_5xx,
615 total_errors,
616 top_routes: routes,
617 error_breakdown: errors,
618 }
619 }
620
621 /// Extract a label value from a Prometheus label string like `method="GET",path="/",status="2xx"`.
622 fn extract_label(labels: &str, key: &str) -> String {
623 let prefix = format!("{key}=\"");
624 labels
625 .split(',')
626 .find_map(|part| {
627 let part = part.trim();
628 part.strip_prefix(&prefix)
629 .and_then(|rest| rest.strip_suffix('"'))
630 .map(std::string::ToString::to_string)
631 })
632 .unwrap_or_default()
633 }
634
635 #[cfg(test)]
636 mod tests {
637 use super::*;
638
639 #[test]
640 fn status_class_mapping() {
641 assert_eq!(status_class(200), "2xx");
642 assert_eq!(status_class(201), "2xx");
643 assert_eq!(status_class(301), "3xx");
644 assert_eq!(status_class(404), "4xx");
645 assert_eq!(status_class(500), "5xx");
646 assert_eq!(status_class(100), "other");
647 }
648
649 #[test]
650 fn public_page_anonymous_is_cdn_cacheable() {
651 let v = cache_control_value("/u/alice", true, false, false);
652 assert!(
653 v.starts_with("public"),
654 "anonymous public page should be CDN-cacheable: {v}"
655 );
656 }
657
658 #[test]
659 fn public_page_authed_viewer_is_private() {
660 // An authenticated viewer's /u /p /i /c page carries owner controls + a
661 // per-session CSRF token, never shared-cache it (Run 13 cross-cutting).
662 assert_eq!(
663 cache_control_value("/i/thing", true, false, true),
664 "private, no-cache"
665 );
666 assert_eq!(
667 cache_control_value("/p/proj", true, false, true),
668 "private, no-cache"
669 );
670 }
671
672 #[test]
673 fn public_page_that_sets_a_cookie_is_private() {
674 // A response minting a session (Set-Cookie) is per-client; the CSRF token
675 // it just created must not be served to anyone else.
676 assert_eq!(
677 cache_control_value("/u/alice", true, true, false),
678 "private, no-cache"
679 );
680 }
681
682 #[test]
683 fn api_routes_are_no_store() {
684 assert_eq!(
685 cache_control_value("/api/items", false, false, false),
686 "no-store"
687 );
688 assert_eq!(
689 cache_control_value("/stripe/webhook", false, false, false),
690 "no-store"
691 );
692 assert_eq!(
693 cache_control_value("/postmark/inbound", false, false, false),
694 "no-store"
695 );
696 }
697
698 #[test]
699 fn private_routes_default_to_no_cache() {
700 assert_eq!(
701 cache_control_value("/dashboard", false, false, true),
702 "private, no-cache"
703 );
704 assert_eq!(
705 cache_control_value("/settings", false, false, false),
706 "private, no-cache"
707 );
708 }
709
710 #[test]
711 fn a_git_page_is_never_shared_cacheable() {
712 // A commit page carries the signed-in reader's own annotations, which
713 // nobody else may see. `/git` is not in `is_public_page`, so it falls
714 // through to `private` already; this pins that, and adding `/git` there
715 // fails here rather than serving one reader's private writing to
716 // another through a shared CDN.
717 assert!(!is_public_page("/git/o/r/commit/abc"));
718 assert_eq!(
719 cache_control_value("/git/o/r/commit/abc", false, false, false),
720 "private, no-cache"
721 );
722 assert!(!is_public_page("/git/my-annotations"));
723 assert_eq!(
724 cache_control_value("/git/my-annotations", false, false, true),
725 "private, no-cache"
726 );
727 }
728 }
729