Skip to main content

quorum_rs/
api_error_middleware.rs

1//! Reusable axum middleware that emits [`TelemetryEvent::ApiError`] on
2//! every HTTP response with `status >= 400`. Mounts on any axum
3//! router an agent crate exposes (status-server dashboard, MCP HTTP
4//! front-end, etc.).
5//!
6//! Agent-side events omit `operator_principal` — agent dashboards
7//! are typically loopback-bound and not authenticated as operators.
8//! Orchestrators emitting the mirror event populate the principal
9//! field from their own auth layer.
10
11use std::sync::Arc;
12
13use axum::{body::Body, extract::MatchedPath, http::Request, middleware::Next, response::Response};
14
15use crate::telemetry::{ApiError, TelemetryContext, TelemetryEmitterMux, TelemetryEvent};
16
17/// Bundle the emitter and a reusable [`TelemetryContext`] so the
18/// middleware can build the event without per-request context
19/// lookups. Status-server-style routes are non-task-scoped, so the
20/// context's `job_id` / `round` / `phase` are `None`.
21#[derive(Clone)]
22pub struct ApiErrorTelemetry {
23    pub emitter: TelemetryEmitterMux,
24    pub ctx: TelemetryContext,
25}
26
27impl ApiErrorTelemetry {
28    /// Build a bundle for an `agent_id`-scoped, non-task-bound
29    /// telemetry context (the typical status-server case).
30    pub fn new(emitter: TelemetryEmitterMux, agent_id: &str) -> Self {
31        let ctx = TelemetryContext::new(agent_id, None, None, None);
32        Self { emitter, ctx }
33    }
34}
35
36/// Middleware entry point. Mount via
37/// `axum::middleware::from_fn_with_state`. The state is a
38/// `Option<Arc<ApiErrorTelemetry>>` so callers can pass `None` to
39/// disable the layer at config time without a separate router shape.
40pub async fn api_error_telemetry_middleware(
41    axum::extract::State(state): axum::extract::State<Option<Arc<ApiErrorTelemetry>>>,
42    req: Request<Body>,
43    next: Next,
44) -> Response {
45    let start = std::time::Instant::now();
46    let method = req.method().as_str().to_string();
47    let endpoint = req
48        .extensions()
49        .get::<MatchedPath>()
50        .map(|m| m.as_str().to_string())
51        .unwrap_or_else(|| req.uri().path().to_string());
52
53    let resp = next.run(req).await;
54    let status = resp.status().as_u16();
55
56    if status >= 400
57        && let Some(t) = state.as_ref()
58    {
59        t.emitter.emit(&TelemetryEvent::ApiError(ApiError {
60            common: t.ctx.common(),
61            http_status: status,
62            error_code: None,
63            endpoint,
64            method,
65            duration_ms: start.elapsed().as_millis() as u64,
66        }));
67    }
68
69    resp
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use axum::http::Method;
76
77    #[test]
78    fn endpoint_label_falls_back_to_raw_path() {
79        let req = Request::builder()
80            .method(Method::GET)
81            .uri("/some/path?q=1")
82            .body(Body::empty())
83            .unwrap();
84        let label = req
85            .extensions()
86            .get::<MatchedPath>()
87            .map(|m| m.as_str().to_string())
88            .unwrap_or_else(|| req.uri().path().to_string());
89        assert_eq!(label, "/some/path");
90    }
91}