use std::fmt;
use std::sync::{Arc, OnceLock};
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
use crate::{CaptureRing, CaptureSink};
struct Armed {
ring: Arc<CaptureRing>,
sink: CaptureSink,
}
#[derive(Clone, Default)]
pub struct CaptureHandle {
armed: Arc<OnceLock<Armed>>,
}
impl CaptureHandle {
pub fn new() -> Self {
Self::default()
}
pub fn arm(&self, ring: Arc<CaptureRing>, sink: CaptureSink) {
let _ = self.armed.set(Armed { ring, sink });
}
fn armed(&self) -> Option<&Armed> {
self.armed.get()
}
}
pub struct CaptureLayer {
handle: CaptureHandle,
}
impl CaptureLayer {
pub fn new(handle: CaptureHandle) -> Self {
Self { handle }
}
}
impl<S: Subscriber> Layer<S> for CaptureLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let Some(armed) = self.handle.armed() else {
return; };
let mut visitor = DecisionVisitor::default();
event.record(&mut visitor);
if !visitor.is_denial() {
return;
}
let Some(request_id) = visitor.request_id else {
return;
};
if let Some(capture) = armed.ring.take(&request_id) {
armed.sink.enqueue(capture);
}
}
}
#[derive(Default)]
struct DecisionVisitor {
message: Option<String>,
request_id: Option<String>,
}
impl DecisionVisitor {
fn is_denial(&self) -> bool {
matches!(self.message.as_deref(), Some("request blocked") | Some("request rejected"))
}
}
impl Visit for DecisionVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
match field.name() {
"message" => self.message = Some(format!("{value:?}")),
"request_id" if self.request_id.is_none() => {
self.request_id = Some(format!("{value:?}"));
}
_ => {}
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "request_id" {
self.request_id = Some(value.to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tracing_subscriber::layer::SubscriberExt;
use crate::CapturedRequest;
fn sample(id: &str) -> CapturedRequest {
CapturedRequest {
request_id: id.to_string(),
method: "POST".to_string(),
path: "/x".to_string(),
query: None,
http_version: "HTTP/1.1".to_string(),
headers: vec![],
cookies: vec![],
body_b64: String::new(),
body_size: 0,
body_truncated: false,
redacted: vec![],
}
}
fn armed_layer() -> (CaptureHandle, Arc<CaptureRing>, tokio::sync::mpsc::Receiver<CapturedRequest>) {
let ring = Arc::new(CaptureRing::new(8, 1 << 20, Duration::from_secs(5)));
let (sink, rx) = CaptureSink::channel(8);
let handle = CaptureHandle::new();
handle.arm(Arc::clone(&ring), sink);
(handle, ring, rx)
}
#[test]
fn denial_event_takes_from_ring_and_ships() {
let (handle, ring, mut rx) = armed_layer();
ring.insert(sample("req-1"));
let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
tracing::subscriber::with_default(subscriber, || {
let rid = "req-1";
tracing::warn!(request_id = %rid, rule_id = "sqli", "request blocked");
});
let got = rx.try_recv().expect("capture shipped");
assert_eq!(got.request_id, "req-1");
assert!(ring.is_empty(), "capture removed from the ring");
}
#[test]
fn rejected_event_also_ships() {
let (handle, ring, mut rx) = armed_layer();
ring.insert(sample("req-2"));
let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
tracing::subscriber::with_default(subscriber, || {
let rid = "req-2";
tracing::warn!(request_id = %rid, status = 429, "request rejected");
});
assert_eq!(rx.try_recv().expect("shipped").request_id, "req-2");
}
#[test]
fn non_denial_event_ships_nothing() {
let (handle, ring, mut rx) = armed_layer();
ring.insert(sample("req-3"));
let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
tracing::subscriber::with_default(subscriber, || {
let rid = "req-3";
tracing::info!(request_id = %rid, method = "GET", "→ request");
});
assert!(rx.try_recv().is_err(), "no capture for a non-denial event");
assert_eq!(ring.len(), 1, "ring untouched");
}
#[test]
fn disarmed_layer_is_inert() {
let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(CaptureHandle::new()));
tracing::subscriber::with_default(subscriber, || {
let rid = "req-x";
tracing::warn!(request_id = %rid, "request blocked");
});
}
}