Skip to main content

rama_http/layer/har/recorder/
mod.rs

1//! Streaming HAR recorder contracts and the built-in file recorder.
2//!
3//! A custom backend implements both [`Recorder`] and [`StreamingRecorder`], with a
4//! per-exchange type implementing [`RecorderSession`].
5//! [`StreamingRecorder::start_http_recording`] receives the request metadata and a
6//! bounded [`BodyCaptureStream`]. The implementation should transfer that stream to
7//! a worker that drains it while the request continues, then return a session that
8//! correlates the request with its eventual response. Likewise,
9//! [`RecorderSession::record_response`] transfers response metadata and its live
10//! body stream to the backend. It must return once the backend has accepted the
11//! stream, rather than waiting for the body to finish. This lets a backend stream
12//! captures to a file, remote collector, or other storage without materializing
13//! complete HTTP bodies in memory.
14//!
15//! For a WebSocket upgrade, the session can return a [`WebSocketCapture`] backed by
16//! a [`WebSocketCaptureRecorder`]. [`HARExportLayer`](super::layer::HARExportLayer)
17//! stores this opaque handle in the successful HTTP handshake metadata. An
18//! explicitly installed Rama WebSocket HAR layer claims it, observes complete
19//! application messages, and releases it when the connection ends or recording
20//! stops. The handshake and WebSocket protocol engine remain independent of HAR
21//! serialization and storage.
22//!
23//! A custom WebSocket backend implements [`WebSocketCaptureRecorder::record`] as an
24//! asynchronous `&self` operation. The future provides natural backpressure: it
25//! completes only after the backend has written the message or handed it to bounded
26//! storage. That future can be dropped before completion when its socket task is
27//! cancelled, so implementations must keep cancellation from corrupting their
28//! backend state. The backend decides whether it needs internal synchronization;
29//! the capture contract itself neither requires exclusive mutable access nor
30//! retains an in-memory message history. Returning `None` from
31//! [`RecorderSession::web_socket_capture`] records only the HTTP exchange.
32
33use super::spec;
34use crate::BodyCaptureEvent;
35use jiff::Timestamp;
36use rama_core::error::BoxError;
37use rama_core::extensions::Extension;
38use rama_http_types::mime::Mime;
39use std::{
40    fmt,
41    future::Future,
42    pin::Pin,
43    sync::{
44        Arc,
45        atomic::{AtomicBool, Ordering},
46    },
47};
48use tokio::sync::mpsc;
49use tokio::time::Instant;
50
51mod fs;
52pub use fs::{FileRecorder, HarFilePath};
53use rama_core::extensions::Extensions;
54use rama_utils::str::NonEmptyStr;
55use rama_utils::str::arcstr::ArcStr;
56
57#[derive(Debug, Clone)]
58/// This object represents the root of exported data.
59pub struct LogMetaInfo {
60    /// Non-empty HAR format version.
61    pub version: NonEmptyStr,
62    /// Name and version info of the log creator application.
63    pub creator: spec::Creator,
64    /// Name and version info of used browser.
65    pub browser: Option<spec::Browser>,
66    /// A comment provided by the user or the application.
67    pub comment: Option<ArcStr>,
68}
69
70impl Default for LogMetaInfo {
71    fn default() -> Self {
72        let log = spec::Log::default();
73        Self {
74            version: log.version,
75            creator: log.creator,
76            browser: log.browser,
77            comment: log.comment,
78        }
79    }
80}
81
82impl From<LogMetaInfo> for spec::Log {
83    fn from(meta: LogMetaInfo) -> Self {
84        Self {
85            version: meta.version,
86            creator: meta.creator,
87            browser: meta.browser,
88            pages: None,
89            entries: Vec::new(),
90            comment: meta.comment,
91        }
92    }
93}
94
95/// Bounded stream of body events observed while an HTTP body is forwarded.
96///
97/// A recorder consumes this stream according to its storage policy. The built-in
98/// file recorder writes each data frame to a temporary file before accepting the
99/// next one, so retained memory is independent of the body's total length.
100#[derive(Debug)]
101pub struct BodyCaptureStream {
102    receiver: mpsc::Receiver<BodyCaptureEvent>,
103}
104
105impl BodyCaptureStream {
106    /// Receive the next captured body event.
107    pub async fn next_event(&mut self) -> Option<BodyCaptureEvent> {
108        self.receiver.recv().await
109    }
110
111    pub(crate) fn try_next_event(&mut self) -> Option<BodyCaptureEvent> {
112        self.receiver.try_recv().ok()
113    }
114}
115
116pub(crate) fn body_capture_channel() -> (mpsc::Sender<BodyCaptureEvent>, BodyCaptureStream) {
117    // Retain at most one frame while the recorder is busy. CaptureBody awaits
118    // the send before forwarding that frame, providing bounded backpressure.
119    let (sender, receiver) = mpsc::channel(1);
120    (sender, BodyCaptureStream { receiver })
121}
122
123/// Request metadata and its live body capture stream.
124#[derive(Debug)]
125pub struct HttpRequestCapture {
126    /// Wall-clock time at which the HTTP exchange started.
127    pub started_date_time: Timestamp,
128    /// Monotonic time at which the HTTP exchange started.
129    pub begin: Instant,
130    /// HAR request metadata available before its streaming body is consumed.
131    pub request: spec::Request,
132    /// Request body media type, when known.
133    pub body_mime_type: Option<Mime>,
134    /// Live request body capture stream.
135    pub body: BodyCaptureStream,
136    /// Whether this request initiated a WebSocket handshake.
137    pub web_socket: bool,
138}
139
140/// Asynchronous recorder for complete Chromium-shaped WebSocket messages.
141///
142/// Each returned future must resolve only after the message has been persisted
143/// or accepted by bounded storage. This lets socket traffic apply backpressure
144/// without prescribing how implementations serialize concurrent calls.
145///
146/// The future may be dropped before it resolves when the WebSocket or its
147/// polling task is dropped. Implementations must therefore be cancellation-safe:
148/// a partially polled call must not corrupt recorder state or permanently retain
149/// capacity reserved for that message. A backend with cancellation-sensitive I/O
150/// can hand the message to an owned worker and await that worker's acknowledgement.
151pub trait WebSocketCaptureRecorder: Send + Sync + 'static {
152    /// Record one WebSocket message.
153    fn record(
154        &self,
155        message: spec::WebSocketMessage,
156    ) -> impl Future<Output = Result<(), BoxError>> + Send + '_;
157}
158
159trait DynWebSocketCaptureRecorder: Send + Sync + 'static {
160    fn record_box(
161        self: Arc<Self>,
162        message: spec::WebSocketMessage,
163    ) -> Pin<Box<dyn Future<Output = Result<(), BoxError>> + Send + 'static>>;
164}
165
166impl<R> DynWebSocketCaptureRecorder for R
167where
168    R: WebSocketCaptureRecorder,
169{
170    fn record_box(
171        self: Arc<Self>,
172        message: spec::WebSocketMessage,
173    ) -> Pin<Box<dyn Future<Output = Result<(), BoxError>> + Send + 'static>> {
174        Box::pin(async move { self.record(message).await })
175    }
176}
177
178struct WebSocketCaptureShared {
179    closed: AtomicBool,
180    close: Box<dyn Fn() + Send + Sync>,
181}
182
183impl WebSocketCaptureShared {
184    fn close(&self) {
185        if !self.closed.swap(true, Ordering::AcqRel) {
186            (self.close)();
187        }
188    }
189}
190
191struct WebSocketCaptureSlot {
192    shared: Arc<WebSocketCaptureShared>,
193    recorder: Arc<dyn DynWebSocketCaptureRecorder>,
194    claimed: AtomicBool,
195}
196
197impl Drop for WebSocketCaptureSlot {
198    fn drop(&mut self) {
199        self.shared.close();
200    }
201}
202
203/// Opaque WebSocket capture handle propagated through HTTP upgrade extensions.
204#[derive(Clone, Extension)]
205#[extension(tags(http))]
206pub struct WebSocketCapture {
207    slot: Arc<WebSocketCaptureSlot>,
208}
209
210impl fmt::Debug for WebSocketCapture {
211    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212        formatter
213            .debug_struct("WebSocketCapture")
214            .finish_non_exhaustive()
215    }
216}
217
218impl WebSocketCapture {
219    /// Create an opaque capture handle with an asynchronous recorder and close callback.
220    ///
221    /// `close` must promptly signal any asynchronous worker associated with the
222    /// recorder. It can race with `record` and must therefore be thread-safe.
223    #[must_use]
224    pub fn new<R, C>(recorder: R, close: C) -> Self
225    where
226        R: WebSocketCaptureRecorder,
227        C: Fn() + Send + Sync + 'static,
228    {
229        let shared = Arc::new(WebSocketCaptureShared {
230            closed: AtomicBool::new(false),
231            close: Box::new(close),
232        });
233        Self {
234            slot: Arc::new(WebSocketCaptureSlot {
235                shared,
236                recorder: Arc::new(recorder),
237                claimed: AtomicBool::new(false),
238            }),
239        }
240    }
241
242    /// Claim this capture and bind completion to one WebSocket's lifetime.
243    ///
244    /// Only the first caller receives a lease. This prevents both sides of a
245    /// proxy relay from recording the same logical messages twice when upgrade
246    /// extensions are propagated across both sockets.
247    #[must_use]
248    pub fn lease(&self) -> Option<WebSocketCaptureLease> {
249        if self.slot.shared.closed.load(Ordering::Acquire)
250            || self
251                .slot
252                .claimed
253                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
254                .is_err()
255        {
256            None
257        } else {
258            Some(WebSocketCaptureLease {
259                slot: self.slot.clone(),
260            })
261        }
262    }
263
264    /// Finish capture explicitly, for example after a rejected upgrade.
265    pub fn close(&self) {
266        self.slot.shared.close();
267    }
268
269    pub(crate) fn close_handle(&self) -> WebSocketCaptureCloseHandle {
270        WebSocketCaptureCloseHandle(self.slot.shared.clone())
271    }
272}
273
274pub(crate) struct WebSocketCaptureCloseHandle(Arc<WebSocketCaptureShared>);
275
276impl WebSocketCaptureCloseHandle {
277    pub(crate) fn close(&self) {
278        self.0.close();
279    }
280}
281
282impl Drop for WebSocketCaptureCloseHandle {
283    fn drop(&mut self) {
284        self.close();
285    }
286}
287
288/// WebSocket-lifetime view of a [`WebSocketCapture`].
289pub struct WebSocketCaptureLease {
290    slot: Arc<WebSocketCaptureSlot>,
291}
292
293/// One in-flight asynchronous WebSocket capture operation.
294///
295/// This named future hides the recorder's internal type erasure from WebSocket
296/// middleware. Custom recorders still implement [`WebSocketCaptureRecorder`]
297/// with an ordinary `impl Future` return.
298pub struct WebSocketCaptureFuture {
299    inner: WebSocketCaptureFutureInner,
300}
301
302enum WebSocketCaptureFutureInner {
303    Closed,
304    Recording(Pin<Box<dyn Future<Output = Result<(), BoxError>> + Send + 'static>>),
305}
306
307impl Future for WebSocketCaptureFuture {
308    type Output = Result<(), BoxError>;
309
310    fn poll(
311        self: Pin<&mut Self>,
312        ctx: &mut std::task::Context<'_>,
313    ) -> std::task::Poll<Self::Output> {
314        match &mut self.get_mut().inner {
315            WebSocketCaptureFutureInner::Closed => std::task::Poll::Ready(Ok(())),
316            WebSocketCaptureFutureInner::Recording(future) => future.as_mut().poll(ctx),
317        }
318    }
319}
320
321impl fmt::Debug for WebSocketCaptureLease {
322    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323        formatter
324            .debug_struct("WebSocketCaptureLease")
325            .finish_non_exhaustive()
326    }
327}
328
329impl WebSocketCaptureLease {
330    /// Return whether this capture has stopped accepting observations.
331    #[must_use]
332    pub fn is_closed(&self) -> bool {
333        self.slot.shared.closed.load(Ordering::Acquire)
334    }
335
336    /// Persist one message, applying recorder-defined backpressure.
337    pub fn record(&self, message: spec::WebSocketMessage) -> WebSocketCaptureFuture {
338        let inner = if self.is_closed() {
339            WebSocketCaptureFutureInner::Closed
340        } else {
341            WebSocketCaptureFutureInner::Recording(self.slot.recorder.clone().record_box(message))
342        };
343        WebSocketCaptureFuture { inner }
344    }
345
346    /// Stop accepting messages and finish this capture.
347    ///
348    /// Calling this more than once is harmless. Cloneable owners may use it to
349    /// coordinate one logical capture across multiple directional adapters.
350    pub fn close(&self) {
351        self.slot.shared.close();
352    }
353}
354
355impl Drop for WebSocketCaptureLease {
356    fn drop(&mut self) {
357        self.slot.shared.close();
358    }
359}
360
361/// Response metadata and its live body capture stream.
362#[derive(Debug)]
363pub struct HttpResponseCapture {
364    /// HAR response metadata available before its streaming body is consumed.
365    pub response: spec::Response,
366    /// Live response body capture stream.
367    pub body: BodyCaptureStream,
368}
369
370/// One in-progress HTTP exchange owned by a [`Recorder`].
371pub trait RecorderSession: Send + 'static {
372    /// Return the capture handle for a WebSocket upgrade, when supported.
373    ///
374    /// [`HARExportLayer`](super::layer::HARExportLayer) calls this at most once
375    /// per session, and only when it classified the request as a WebSocket
376    /// handshake.
377    fn web_socket_capture(&self) -> Option<WebSocketCapture> {
378        None
379    }
380
381    /// Attach a response and its streaming body to this exchange.
382    ///
383    /// This future must resolve after the recorder has accepted the stream, not
384    /// after the response body ends. Otherwise returning a streaming response
385    /// would deadlock on its own consumer.
386    fn record_response(
387        self,
388        response: HttpResponseCapture,
389    ) -> impl Future<Output = Option<Extensions>> + Send;
390
391    /// Finish an exchange which has no recordable response.
392    fn record_request_only(self) -> impl Future<Output = Option<Extensions>> + Send;
393}
394
395pub trait Recorder: Send + Sync + 'static {
396    /// Record an already materialized HAR log.
397    ///
398    /// Prefer [`StreamingRecorder::start_http_recording`] for live HTTP traffic
399    /// so body data can remain streaming.
400    fn record(&self, entry: spec::Log) -> impl Future<Output = Option<Extensions>> + Send + '_;
401
402    /// Finish the active recording before this future resolves.
403    ///
404    /// This function can be called when no session is active, which a recorder
405    /// must handle as a no-op.
406    fn stop_record(&self) -> impl Future<Output = ()> + Send;
407}
408
409impl<R: Recorder> Recorder for Arc<R> {
410    fn record(&self, log: spec::Log) -> impl Future<Output = Option<Extensions>> + Send + '_ {
411        (**self).record(log)
412    }
413
414    fn stop_record(&self) -> impl Future<Output = ()> + Send {
415        (**self).stop_record()
416    }
417}
418
419/// Recorder capable of consuming live HTTP bodies without materializing them.
420pub trait StreamingRecorder: Recorder {
421    /// Per-exchange recording handle.
422    type Session: RecorderSession;
423
424    /// Start capturing an HTTP exchange.
425    ///
426    /// This future must resolve after the recorder has accepted ownership of
427    /// the request stream. Body data continues to flow after it returns.
428    fn start_http_recording(
429        &self,
430        request: HttpRequestCapture,
431    ) -> impl Future<Output = Option<Self::Session>> + Send + '_;
432}
433
434impl<R: StreamingRecorder> StreamingRecorder for Arc<R> {
435    type Session = R::Session;
436
437    fn start_http_recording(
438        &self,
439        request: HttpRequestCapture,
440    ) -> impl Future<Output = Option<Self::Session>> + Send + '_ {
441        (**self).start_http_recording(request)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use parking_lot::Mutex;
449    use std::sync::atomic::{AtomicUsize, Ordering};
450
451    #[derive(Default)]
452    struct TestWebSocketState {
453        messages: Mutex<Vec<spec::WebSocketMessage>>,
454        closes: AtomicUsize,
455    }
456
457    struct TestWebSocketRecorder(Arc<TestWebSocketState>);
458
459    impl WebSocketCaptureRecorder for TestWebSocketRecorder {
460        async fn record(&self, message: spec::WebSocketMessage) -> Result<(), BoxError> {
461            self.0.messages.lock().push(message);
462            Ok(())
463        }
464    }
465
466    #[tokio::test]
467    async fn web_socket_capture_has_one_lifetime_owner() {
468        let state = Arc::new(TestWebSocketState::default());
469        let capture = WebSocketCapture::new(TestWebSocketRecorder(state.clone()), {
470            let state = state.clone();
471            move || {
472                state.closes.fetch_add(1, Ordering::AcqRel);
473            }
474        });
475        let lease = capture.lease().expect("first claimant");
476        assert!(
477            capture.lease().is_none(),
478            "second claimant must be rejected"
479        );
480
481        lease
482            .record(spec::WebSocketMessage::text(
483                spec::WebSocketMessageType::Send,
484                1.0,
485                "hello",
486            ))
487            .await
488            .unwrap();
489        assert_eq!(state.messages.lock().len(), 1);
490        assert_eq!(state.closes.load(Ordering::Acquire), 0);
491
492        drop(lease);
493        assert_eq!(state.closes.load(Ordering::Acquire), 1);
494        drop(capture);
495        assert_eq!(state.closes.load(Ordering::Acquire), 1);
496    }
497
498    #[test]
499    fn dropping_close_handle_stops_web_socket_capture() {
500        let state = Arc::new(TestWebSocketState::default());
501        let capture = WebSocketCapture::new(TestWebSocketRecorder(state.clone()), {
502            let state = state.clone();
503            move || {
504                state.closes.fetch_add(1, Ordering::AcqRel);
505            }
506        });
507
508        drop(capture.close_handle());
509
510        assert_eq!(state.closes.load(Ordering::Acquire), 1);
511        assert!(capture.lease().is_none(), "closed capture rejects leases");
512    }
513
514    #[test]
515    fn explicitly_closed_web_socket_capture_rejects_claims() {
516        let state = Arc::new(TestWebSocketState::default());
517        let capture = WebSocketCapture::new(TestWebSocketRecorder(state.clone()), {
518            let state = state.clone();
519            move || {
520                state.closes.fetch_add(1, Ordering::AcqRel);
521            }
522        });
523
524        capture.close();
525        capture.close();
526        assert_eq!(state.closes.load(Ordering::Acquire), 1);
527        assert!(
528            capture.lease().is_none(),
529            "closed capture cannot be claimed"
530        );
531    }
532
533    #[tokio::test]
534    async fn explicitly_closed_web_socket_lease_stops_recording() {
535        let state = Arc::new(TestWebSocketState::default());
536        let capture = WebSocketCapture::new(TestWebSocketRecorder(state.clone()), {
537            let state = state.clone();
538            move || {
539                state.closes.fetch_add(1, Ordering::AcqRel);
540            }
541        });
542        let lease = capture.lease().expect("capture lease");
543
544        lease.close();
545        lease
546            .record(spec::WebSocketMessage::text(
547                spec::WebSocketMessageType::Send,
548                1.0,
549                "ignored",
550            ))
551            .await
552            .unwrap();
553
554        assert_eq!(state.closes.load(Ordering::Acquire), 1);
555        assert!(state.messages.lock().is_empty());
556    }
557
558    #[test]
559    fn unclaimed_web_socket_capture_closes_on_drop() {
560        let state = Arc::new(TestWebSocketState::default());
561        let capture = WebSocketCapture::new(TestWebSocketRecorder(state.clone()), {
562            let state = state.clone();
563            move || {
564                state.closes.fetch_add(1, Ordering::AcqRel);
565            }
566        });
567
568        drop(capture);
569        assert_eq!(state.closes.load(Ordering::Acquire), 1);
570    }
571}