Skip to main content

max / makenotwork

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