mesofact-core 0.8.43

mesofact serve/proxy engine — tri-mode web server core (static push-to-CDN / SSR proxy / SPA shell). Foundation crate re-exported by the `mesofact` facade.
Documentation
//! Prometheus metrics for the proxy (`/metrics` exposition, scraped by yubaba).
//!
//! MVP metric set from the architecture's §"Observability":
//!
//! | Metric | Type | Labels |
//! |---|---|---|
//! | `mesofact_requests_total` | counter | `route, mode, status` |
//! | `mesofact_render_duration_seconds` | histogram | `route` |
//! | `mesofact_cache_total` | counter | `route, state` (fresh/stale/miss) |
//! | `mesofact_worker_pool` | gauge | `state` (ready/busy/restarting) |
//!
//! Hand-rolled rather than pulling in the `prometheus` crate — the surface is
//! four metrics and a text encoder, matching the project's lean-deps posture
//! (cf. the publisher's hand-rolled SigV4). Counters/gauges live behind a
//! `Mutex` keyed by label tuples; `render()` walks them into the 0.0.4 text
//! exposition format.
//!
//! @yah:ticket(R012-T3, "Observability: Prometheus /metrics exporter (requests/render_duration/cache/worker_pool) + W3C traceparent generate/accept + passthrough to worker via req.ctx.trace")
//! @yah:at(2026-05-26T16:15:53Z)
//! @yah:status(review)
//! @yah:assignee(agent:claude)
//! @yah:phase(P10)
//! @yah:parent(R012)
//! @yah:handoff("Observability MVP shipped. (A) metrics.rs: hand-rolled Prometheus registry (no prometheus crate) — mesofact_requests_total{route,mode,status} counter, mesofact_render_duration_seconds{route} histogram (cumulative le-buckets 5ms..10s + sum/count), mesofact_cache_total{route,state} (fresh/stale/miss), mesofact_worker_pool{state} gauge (ready/busy/restarting). render(ready) emits 0.0.4 text exposition; label values escaped. (B) trace.rs: W3C traceparent — TraceParent::incoming_or_new() continues a valid inbound (reuse trace-id, mint new span) or generates fresh (SplitMix64 over nanos^counter, no CSPRNG dep); header_value() = '00-{trace}-{span}-01'; parse rejects bad version/length/all-zero. (C) router.rs: handle() refactored to a single funnel — generates traceparent, dispatches via a Plan enum, echoes 'traceparent' response header, records requests_total once (incl. <unmatched> 404s). dispatch_ssr/render_miss/spawn_refresh record cache_total + observe render_duration + inc/dec the busy gauge; build_render_request injects ctx.trace. New pub metrics_handler (GET /metrics, bypasses the manifest matcher). (D) worker_pool.rs: live_count() (ready gauge) + attach_metrics() + restarting_inc/dec around respawn. (E) mesofact-proxy.rs: one shared Arc<Metrics> wired into AppState + pool (initial + reload), /metrics route mounted. Tests: 27 lib unit (4 metrics + 4 trace), 3 new proxy integration (metrics scrape after a render shows requests/cache/render/ready; trace_echo.ts stub proves req.ctx.trace == echoed traceparent; inbound trace-id continued).")
//! @yah:verify("cargo test -p mesofact")
//! @yah:verify("cargo check --workspace")
//! @yah:cleanup("mesofact_worker_pool{state=ready} = pool.live_count() always equals n (dead workers are replaced in-place, not removed), so 'ready' is really pool size; a truly-live count would require per-worker health state. 'busy' (proxy-side in-flight renders) + 'restarting' are accurate.")
//! @yah:assumes("Worker logging req.ctx.trace on each render (the DoD 'trace flows proxy→worker log line') is left to the operator/worker; the proxy→worker passthrough is verified by trace_echo.ts returning ctx.trace. Adapter child spans + OTLP export are post-MVP per the design.")

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Mutex;

/// Cumulative histogram upper bounds (seconds) for render duration. The
/// implicit `+Inf` bucket equals the total count.
const DURATION_BUCKETS: &[f64] = &[
    0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];

/// Per-route render-duration histogram. `buckets[i]` is the cumulative count of
/// observations `<= DURATION_BUCKETS[i]` (each observation bumps every bucket
/// whose bound it falls under, so the stored value is already cumulative).
struct Histogram {
    buckets: Vec<u64>,
    sum: f64,
    count: u64,
}

impl Histogram {
    fn new() -> Self {
        Self {
            buckets: vec![0; DURATION_BUCKETS.len()],
            sum: 0.0,
            count: 0,
        }
    }

    fn observe(&mut self, secs: f64) {
        self.count += 1;
        self.sum += secs;
        for (i, bound) in DURATION_BUCKETS.iter().enumerate() {
            if secs <= *bound {
                self.buckets[i] += 1;
            }
        }
    }
}

/// Proxy metrics registry. Cheap to clone behind an `Arc`; all mutation goes
/// through interior `Mutex`/atomics so handlers can share one instance.
pub struct Metrics {
    /// (route, mode, status) → count.
    requests: Mutex<BTreeMap<(String, String, u16), u64>>,
    /// (route, state) → count, state ∈ {fresh, stale, miss}.
    cache: Mutex<BTreeMap<(String, String), u64>>,
    /// route → render-duration histogram.
    render: Mutex<BTreeMap<String, Histogram>>,
    /// In-flight renders (proxy-side) — the `busy` worker-pool gauge.
    render_inflight: AtomicI64,
    /// Workers currently being respawned — the `restarting` worker-pool gauge.
    restarting: AtomicI64,
}

impl Default for Metrics {
    fn default() -> Self {
        Self::new()
    }
}

impl Metrics {
    pub fn new() -> Self {
        Self {
            requests: Mutex::new(BTreeMap::new()),
            cache: Mutex::new(BTreeMap::new()),
            render: Mutex::new(BTreeMap::new()),
            render_inflight: AtomicI64::new(0),
            restarting: AtomicI64::new(0),
        }
    }

    pub fn record_request(&self, route: &str, mode: &str, status: u16) {
        *self
            .requests
            .lock()
            .unwrap()
            .entry((route.to_string(), mode.to_string(), status))
            .or_insert(0) += 1;
    }

    pub fn record_cache(&self, route: &str, state: &str) {
        *self
            .cache
            .lock()
            .unwrap()
            .entry((route.to_string(), state.to_string()))
            .or_insert(0) += 1;
    }

    pub fn observe_render(&self, route: &str, secs: f64) {
        self.render
            .lock()
            .unwrap()
            .entry(route.to_string())
            .or_insert_with(Histogram::new)
            .observe(secs);
    }

    pub fn inflight_inc(&self) {
        self.render_inflight.fetch_add(1, Ordering::Relaxed);
    }

    pub fn inflight_dec(&self) {
        self.render_inflight.fetch_sub(1, Ordering::Relaxed);
    }

    pub fn restarting_inc(&self) {
        self.restarting.fetch_add(1, Ordering::Relaxed);
    }

    pub fn restarting_dec(&self) {
        self.restarting.fetch_sub(1, Ordering::Relaxed);
    }

    /// Render the Prometheus text exposition (format 0.0.4). `ready` is the
    /// current live worker count, queried from the pool at scrape time.
    pub fn render(&self, ready: usize) -> String {
        let mut out = String::new();

        out.push_str("# HELP mesofact_requests_total Total HTTP requests handled.\n");
        out.push_str("# TYPE mesofact_requests_total counter\n");
        for ((route, mode, status), count) in self.requests.lock().unwrap().iter() {
            out.push_str(&format!(
                "mesofact_requests_total{{route=\"{}\",mode=\"{}\",status=\"{}\"}} {}\n",
                esc(route),
                esc(mode),
                status,
                count,
            ));
        }

        out.push_str("# HELP mesofact_render_duration_seconds Render duration through the Bun pool.\n");
        out.push_str("# TYPE mesofact_render_duration_seconds histogram\n");
        for (route, hist) in self.render.lock().unwrap().iter() {
            let r = esc(route);
            for (i, bound) in DURATION_BUCKETS.iter().enumerate() {
                out.push_str(&format!(
                    "mesofact_render_duration_seconds_bucket{{route=\"{}\",le=\"{}\"}} {}\n",
                    r, bound, hist.buckets[i],
                ));
            }
            out.push_str(&format!(
                "mesofact_render_duration_seconds_bucket{{route=\"{}\",le=\"+Inf\"}} {}\n",
                r, hist.count,
            ));
            out.push_str(&format!(
                "mesofact_render_duration_seconds_sum{{route=\"{}\"}} {}\n",
                r, hist.sum,
            ));
            out.push_str(&format!(
                "mesofact_render_duration_seconds_count{{route=\"{}\"}} {}\n",
                r, hist.count,
            ));
        }

        out.push_str("# HELP mesofact_cache_total Mode 2 cache outcomes by state.\n");
        out.push_str("# TYPE mesofact_cache_total counter\n");
        for ((route, state), count) in self.cache.lock().unwrap().iter() {
            out.push_str(&format!(
                "mesofact_cache_total{{route=\"{}\",state=\"{}\"}} {}\n",
                esc(route),
                esc(state),
                count,
            ));
        }

        out.push_str("# HELP mesofact_worker_pool Worker pool size by state.\n");
        out.push_str("# TYPE mesofact_worker_pool gauge\n");
        out.push_str(&format!(
            "mesofact_worker_pool{{state=\"ready\"}} {}\n",
            ready,
        ));
        out.push_str(&format!(
            "mesofact_worker_pool{{state=\"busy\"}} {}\n",
            self.render_inflight.load(Ordering::Relaxed).max(0),
        ));
        out.push_str(&format!(
            "mesofact_worker_pool{{state=\"restarting\"}} {}\n",
            self.restarting.load(Ordering::Relaxed).max(0),
        ));

        out
    }
}

/// Escape a Prometheus label value: backslash, double-quote, and newline per
/// the exposition spec. Route patterns (`/`, `:param`) need no escaping but a
/// `vary`-derived or future label might.
fn esc(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            _ => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn requests_counter_groups_by_label_tuple() {
        let m = Metrics::new();
        m.record_request("/", "static", 200);
        m.record_request("/", "static", 200);
        m.record_request("/api", "ssr", 503);
        let out = m.render(2);
        assert!(out.contains("mesofact_requests_total{route=\"/\",mode=\"static\",status=\"200\"} 2"));
        assert!(out.contains("mesofact_requests_total{route=\"/api\",mode=\"ssr\",status=\"503\"} 1"));
    }

    #[test]
    fn histogram_buckets_are_cumulative() {
        let m = Metrics::new();
        m.observe_render("/api", 0.02); // <= 0.025, 0.05, ...
        m.observe_render("/api", 0.2); // <= 0.25, 0.5, ...
        let out = m.render(1);
        // le=0.025 catches the 0.02 sample only.
        assert!(out.contains("mesofact_render_duration_seconds_bucket{route=\"/api\",le=\"0.025\"} 1"));
        // le=0.5 catches both.
        assert!(out.contains("mesofact_render_duration_seconds_bucket{route=\"/api\",le=\"0.5\"} 2"));
        assert!(out.contains("mesofact_render_duration_seconds_bucket{route=\"/api\",le=\"+Inf\"} 2"));
        assert!(out.contains("mesofact_render_duration_seconds_count{route=\"/api\"} 2"));
    }

    #[test]
    fn cache_and_pool_gauges_render() {
        let m = Metrics::new();
        m.record_cache("/api", "miss");
        m.record_cache("/api", "fresh");
        m.inflight_inc();
        let out = m.render(4);
        assert!(out.contains("mesofact_cache_total{route=\"/api\",state=\"miss\"} 1"));
        assert!(out.contains("mesofact_cache_total{route=\"/api\",state=\"fresh\"} 1"));
        assert!(out.contains("mesofact_worker_pool{state=\"ready\"} 4"));
        assert!(out.contains("mesofact_worker_pool{state=\"busy\"} 1"));
        assert!(out.contains("mesofact_worker_pool{state=\"restarting\"} 0"));
    }

    #[test]
    fn label_values_are_escaped() {
        assert_eq!(esc("a\"b\\c"), "a\\\"b\\\\c");
    }
}