Skip to main content

arcly_http/web/
interceptors.rs

1//! Aspect-oriented interceptors.
2//!
3//! An [`Interceptor`] sits *around* a handler: it sees the inbound
4//! `RequestContext` before extraction and the `Response` on the way out. Use
5//! it for logging, timing, response envelopes, caching — anything that
6//! shouldn't live in handler bodies.
7//!
8//! Apply with the `#[UseInterceptors(...)]` attribute on a handler or
9//! controller `impl` block. Multiple interceptors compose in declaration
10//! order (first listed = outermost layer).
11
12use std::sync::Arc;
13
14use axum::response::Response;
15use futures::future::BoxFuture;
16
17use crate::web::context::RequestContext;
18
19/// Continuation handed to an interceptor. Calling `.run(ctx)` invokes the
20/// next layer (or the handler itself if this is the innermost interceptor).
21pub struct NextHandler {
22    inner: Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync>,
23}
24
25impl NextHandler {
26    #[doc(hidden)]
27    pub fn new<F>(f: F) -> Self
28    where
29        F: Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync + 'static,
30    {
31        Self { inner: Arc::new(f) }
32    }
33
34    #[inline]
35    pub fn run(&self, ctx: RequestContext) -> BoxFuture<'static, Response> {
36        (self.inner)(ctx)
37    }
38
39    /// Internal: produce a cheap clone for passing through composed chains
40    /// without forcing the public API into `Clone`.
41    #[doc(hidden)]
42    #[inline]
43    pub fn __clone_for_chain(&self) -> Self {
44        Self {
45            inner: Arc::clone(&self.inner),
46        }
47    }
48}
49
50/// One layer of the aspect chain.
51///
52/// `around` *must* call `next.run(ctx)` (typically once) to invoke the inner
53/// layer. Returning a response without calling `next` short-circuits the
54/// chain — useful for caches and rate limits.
55pub trait Interceptor: Send + Sync + 'static {
56    fn around(
57        &'static self,
58        ctx: RequestContext,
59        next: NextHandler,
60    ) -> BoxFuture<'static, Response>;
61}
62
63/// Compose a chain of interceptors around a terminal handler. Declaration
64/// order matters: the first interceptor in `globals` is the outermost layer.
65/// Used by the launch path to wrap plugin-registered global interceptors
66/// around every mounted route (macro and plugin alike).
67pub(crate) fn compose_chain(
68    globals: &'static [&'static dyn Interceptor],
69    terminal: Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync>,
70) -> Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync> {
71    let mut chain = terminal;
72    for ic in globals.iter().rev() {
73        let inner = chain;
74        let ic: &'static dyn Interceptor = *ic;
75        chain = Arc::new(move |ctx| {
76            let next = NextHandler {
77                inner: Arc::clone(&inner),
78            };
79            ic.around(ctx, next)
80        });
81    }
82    chain
83}
84
85// ─── Stock interceptors ─────────────────────────────────────────────────
86
87/// Log method + path + status + latency on each request.
88pub struct LatencyLog;
89impl Interceptor for LatencyLog {
90    fn around(
91        &'static self,
92        ctx: RequestContext,
93        next: NextHandler,
94    ) -> BoxFuture<'static, Response> {
95        Box::pin(async move {
96            let method = ctx.method().clone();
97            let path = ctx.path().to_owned();
98            let started = std::time::Instant::now();
99            let resp = next.run(ctx).await;
100            let micros = started.elapsed().as_micros();
101            println!(
102                "[arcly] {method} {path} -> {} in {micros}\u{00B5}s",
103                resp.status().as_u16()
104            );
105            resp
106        })
107    }
108}
109
110/// Emit a structured log line for every request via the `tracing` crate.
111///
112/// Fields: `trace_id`, `span_id`, `method`, `path`, `status`, `duration_ms`.
113/// Level is chosen by status: `error` (5xx), `warn` (4xx), `info` (2xx/3xx).
114///
115/// When `ArclyObservabilityPlugin` is active, these log lines are formatted as
116/// JSON and emitted to stdout. Without the plugin, they use `tracing`'s default
117/// formatter. For full OTLP traces + Prometheus metrics use `TraceInterceptor`.
118pub struct TelemetryLog;
119
120impl Interceptor for TelemetryLog {
121    fn around(
122        &'static self,
123        ctx: RequestContext,
124        next: NextHandler,
125    ) -> BoxFuture<'static, Response> {
126        Box::pin(async move {
127            use crate::web::context::{hex_encode_16, hex_encode_8};
128            let method = ctx.method().to_string();
129            let path = ctx.path().to_owned();
130            let trace_id = hex_encode_16(&ctx.trace_id());
131            let span_id = hex_encode_8(&ctx.span_id());
132            let started = std::time::Instant::now();
133
134            let resp = next.run(ctx).await;
135
136            let status = resp.status().as_u16();
137            let duration_ms = started.elapsed().as_millis() as u64;
138
139            if status >= 500 {
140                tracing::error!(
141                    trace_id,
142                    span_id,
143                    method,
144                    path,
145                    status,
146                    duration_ms,
147                    "request failed"
148                );
149            } else if status >= 400 {
150                tracing::warn!(
151                    trace_id,
152                    span_id,
153                    method,
154                    path,
155                    status,
156                    duration_ms,
157                    "request error"
158                );
159            } else {
160                tracing::info!(
161                    trace_id,
162                    span_id,
163                    method,
164                    path,
165                    status,
166                    duration_ms,
167                    "request completed"
168                );
169            }
170
171            resp
172        })
173    }
174}
175
176/// Full-stack observability interceptor: structured log + OTLP span + Prometheus.
177///
178/// Use this instead of `TelemetryLog` when `ArclyObservabilityPlugin` is active.
179/// Apply with `#[UseInterceptors(TraceInterceptor)]` on a handler or controller.
180///
181/// What it does per request:
182/// 1. Creates an OpenTelemetry server span as a child of the incoming W3C parent.
183/// 2. Records HTTP semantic-convention attributes on the span.
184/// 3. Records `http_requests_total` counter + `http_request_duration_seconds`
185///    histogram in the Prometheus registry.
186/// 4. Emits a `tracing::info/warn/error!` log with all correlation fields.
187pub struct TraceInterceptor;
188
189/// Calls `span.end()` on drop so the span is always finalised — even if the
190/// handler panics or the interceptor future is cancelled.
191struct SpanGuard<S: opentelemetry::trace::Span>(S);
192
193impl<S: opentelemetry::trace::Span> Drop for SpanGuard<S> {
194    fn drop(&mut self) {
195        self.0.end();
196    }
197}
198
199impl Interceptor for TraceInterceptor {
200    fn around(
201        &'static self,
202        ctx: RequestContext,
203        next: NextHandler,
204    ) -> BoxFuture<'static, Response> {
205        Box::pin(async move {
206            use crate::observability::metrics::record_request;
207            use crate::observability::otel::parent_context_from;
208            use crate::web::context::{hex_encode_16, hex_encode_8};
209            use opentelemetry::trace::{Span, SpanKind, Status, Tracer};
210            use opentelemetry::KeyValue;
211
212            let method = ctx.method().to_string();
213            let route = ctx.route().to_owned();
214            let trace_id = hex_encode_16(&ctx.trace_id());
215            let span_id = hex_encode_8(&ctx.span_id());
216            let started = std::time::Instant::now();
217
218            // ── 1. Start OTEL child span ────────────────────────────────────
219            let parent_cx = parent_context_from(&ctx);
220            let tracer = opentelemetry::global::tracer("arcly-http");
221            let span_name = format!("{method} {route}");
222            let mut span = SpanGuard(
223                tracer
224                    .span_builder(span_name)
225                    .with_kind(SpanKind::Server)
226                    .start_with_context(&tracer, &parent_cx),
227            );
228            span.0
229                .set_attribute(KeyValue::new("http.request.method", method.clone()));
230            span.0
231                .set_attribute(KeyValue::new("http.route", route.clone()));
232
233            // ── 2. Execute the handler ──────────────────────────────────────
234            let resp = next.run(ctx).await;
235
236            // ── 3. Finalise span (SpanGuard::drop calls span.end()) ─────────
237            let status = resp.status().as_u16();
238            let elapsed = started.elapsed();
239            let duration_ms = elapsed.as_millis() as u64;
240
241            span.0
242                .set_attribute(KeyValue::new("http.response.status_code", status as i64));
243            if status >= 500 {
244                span.0.set_status(Status::error("server error"));
245            }
246
247            // ── 4. Prometheus metrics ───────────────────────────────────────
248            record_request(&route, &method, status, elapsed.as_secs_f64());
249
250            // ── 5. Structured log ───────────────────────────────────────────
251            if status >= 500 {
252                tracing::error!(
253                    trace_id,
254                    span_id,
255                    method,
256                    route,
257                    status,
258                    duration_ms,
259                    "request failed"
260                );
261            } else if status >= 400 {
262                tracing::warn!(
263                    trace_id,
264                    span_id,
265                    method,
266                    route,
267                    status,
268                    duration_ms,
269                    "request error"
270                );
271            } else {
272                tracing::info!(
273                    trace_id,
274                    span_id,
275                    method,
276                    route,
277                    status,
278                    duration_ms,
279                    "request completed"
280                );
281            }
282
283            resp
284        })
285    }
286}
287
288/// Wrap successful JSON responses in `{ "data": ... }`. Errors pass through
289/// untouched so RFC 7807 ProblemDetails reaches the client verbatim.
290pub struct EnvelopeResponse;
291impl Interceptor for EnvelopeResponse {
292    fn around(
293        &'static self,
294        ctx: RequestContext,
295        next: NextHandler,
296    ) -> BoxFuture<'static, Response> {
297        Box::pin(async move {
298            use axum::body::to_bytes;
299            let resp = next.run(ctx).await;
300            if !resp.status().is_success() {
301                return resp;
302            }
303            let (parts, body) = resp.into_parts();
304            let bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
305            let inner: serde_json::Value =
306                serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
307            let wrapped = serde_json::json!({ "data": inner });
308            let body = axum::body::Body::from(serde_json::to_vec(&wrapped).unwrap());
309            Response::from_parts(parts, body)
310        })
311    }
312}