Skip to main content

max / makenotwork

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