//! HTTP request telemetry: one span per request, constant name, templated
//! route — the shape the s10 console's `/perf` page recognises.
use axum::extract::{MatchedPath, Request, State};
use axum::middleware::Next;
use axum::response::Response;
use jac_telemetry::{Envelope, SpanStatus};
use crate::error::ErrorSignal;
use crate::state::SharedState;
/// Time source for span boundaries. Not `SystemClock` (that's a jacquard
/// engine port, one per repo); this is the surface's own wall clock, the
/// same allowance `SystemClock`'s own adapter takes.
#[expect(
clippy::disallowed_types,
reason = "the one place jac-serve reads wall time directly, for span timestamps outside any repo"
)]
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
}
/// Wraps every request in a `http.server.request` span. `/api/health` is
/// excluded — a liveness probe is not a signal worth a tenant's attention.
pub(crate) async fn span_middleware(
State(state): State<SharedState>,
matched: Option<MatchedPath>,
req: Request,
next: Next,
) -> Response {
let method = req.method().to_string();
let route = matched.map_or_else(|| "unmatched".to_owned(), |m| m.as_str().to_owned());
if route == "/api/health" {
return next.run(req).await;
}
let start = now_ms();
let response = next.run(req).await;
let end = now_ms();
let status = response.status().as_u16();
let span_status = if status >= 500 {
SpanStatus::Error
} else {
SpanStatus::Ok
};
let fault = response.extensions().get::<ErrorSignal>().cloned();
state.telemetry.emit(
Envelope::span("http.server.request", start, end, span_status)
.attr("http.method", method.clone())
.attr("http.route", route.clone())
.attr("http.status_code", status.to_string()),
);
if status >= 500
&& let Some(ErrorSignal(message)) = fault
{
state.telemetry.emit(
Envelope::bug(message, None, end)
.attr("http.method", method)
.attr("http.route", route),
);
}
response
}