af-web 0.2.0

Reusable axum web infrastructure: request-id + latency-SLO middleware, JSON error envelope, health. The cross-cutting half of agent_core/api/.
Documentation
//! Cross-cutting middleware. Ports the reusable concerns of `agent_core/api/`:
//! request-id propagation ([`request_id`]) and the API latency SLO
//! ([`latency_slo`]).

use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;

use axum::extract::Request;
use axum::http::{HeaderName, HeaderValue};
use axum::middleware::Next;
use axum::response::Response;

static REQ_SEQ: AtomicU64 = AtomicU64::new(1);

/// The API latency budget (p95 ≤ 1s is the product line). Requests over this
/// are logged at `warn` so a regression surfaces without a profiler.
pub const SLO_BUDGET_MS: u128 = 1_000;

const REQUEST_ID_HEADER: &str = "x-request-id";
const RESPONSE_TIME_HEADER: &str = "x-response-time-ms";

/// A request id, available to handlers via `Extension<RequestId>`.
#[derive(Debug, Clone)]
pub struct RequestId(pub String);

/// Mint (or honor a client-supplied) request id, stash it in extensions for
/// handlers, and echo it on the response.
pub async fn request_id(mut req: Request, next: Next) -> Response {
    let id = req
        .headers()
        .get(REQUEST_ID_HEADER)
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string())
        .unwrap_or_else(|| format!("req_{:016x}", REQ_SEQ.fetch_add(1, Ordering::Relaxed)));

    req.extensions_mut().insert(RequestId(id.clone()));
    let mut resp = next.run(req).await;

    if let Ok(value) = HeaderValue::from_str(&id) {
        resp.headers_mut()
            .insert(HeaderName::from_static(REQUEST_ID_HEADER), value);
    }
    resp
}

/// Time each request, attach `x-response-time-ms`, and warn past the SLO budget.
pub async fn latency_slo(req: Request, next: Next) -> Response {
    let method = req.method().clone();
    let path = req.uri().path().to_string();
    let request_id = req
        .extensions()
        .get::<RequestId>()
        .map(|r| r.0.clone())
        .unwrap_or_default();

    let started = Instant::now();
    let mut resp = next.run(req).await;
    let elapsed = started.elapsed();
    let ms = elapsed.as_millis();

    if let Ok(value) = HeaderValue::from_str(&ms.to_string()) {
        resp.headers_mut()
            .insert(HeaderName::from_static(RESPONSE_TIME_HEADER), value);
    }

    if ms > SLO_BUDGET_MS {
        tracing::warn!(
            target: "web.slo",
            %method, path, request_id, elapsed_ms = ms, status = resp.status().as_u16(),
            "api_slo_violation"
        );
    } else {
        tracing::debug!(
            target: "web.request",
            %method, path, request_id, elapsed_ms = ms, status = resp.status().as_u16(),
            "request"
        );
    }
    resp
}