leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use std::fmt;
use std::future::Future;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};

use crate::{Error, HttpVersion};

mod body;
mod fanout;
mod headers;
mod metrics;
mod notes;
mod summary;

pub(crate) use body::BodyWatch;
pub use body::{BodyEnd, BodyOutcome};
pub use fanout::Fanout;
pub(crate) use headers::masked;
pub use metrics::{Metrics, MetricsSnapshot};
pub(crate) use notes::{note_browser, note_proxy, note_tag};
pub use summary::Summary;
pub(crate) use summary::{Finish, summary};

#[derive(Debug)]
#[non_exhaustive]
pub struct Dns<'a> {
    pub id: u64,
    pub host: &'a str,
    pub port: u16,
    pub addrs: usize,
    pub elapsed: Duration,
}

#[derive(Debug)]
#[non_exhaustive]
pub struct Connect<'a> {
    pub id: u64,
    pub host: &'a str,
    pub port: u16,
    pub reused: bool,
    pub elapsed: Duration,
}

#[derive(Debug)]
#[non_exhaustive]
pub struct Tls<'a> {
    pub id: u64,
    pub host: &'a str,
    pub version: Option<&'a str>,
    pub cipher: Option<&'a str>,
    pub alpn: Option<&'a str>,
    pub elapsed: Duration,
}

#[non_exhaustive]
pub struct Sent<'a> {
    pub id: u64,
    pub host: &'a str,
    pub method: &'a str,
    pub path: &'a str,
    pub protocol: HttpVersion,
    pub elapsed: Duration,
}

impl std::fmt::Debug for Sent<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Sent")
            .field("id", &self.id)
            .field("host", &self.host)
            .field("method", &self.method)
            .field("path", &crate::util::redact_target(self.path))
            .field("protocol", &self.protocol)
            .field("elapsed", &self.elapsed)
            .finish()
    }
}

#[non_exhaustive]
pub struct Head<'a> {
    pub id: u64,
    pub host: &'a str,
    pub status: u16,
    pub protocol: HttpVersion,
    pub elapsed: Duration,
    pub headers: &'a http::HeaderMap,
}

impl fmt::Debug for Head<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Head")
            .field("id", &self.id)
            .field("host", &self.host)
            .field("status", &self.status)
            .field("protocol", &self.protocol)
            .field("elapsed", &self.elapsed)
            .field(
                "headers",
                &masked(self.headers.iter().map(|(k, v)| (k.as_str(), v.as_bytes()))),
            )
            .finish()
    }
}

#[derive(Debug)]
#[non_exhaustive]
pub struct Done<'a> {
    pub id: u64,
    pub elapsed: Duration,
    pub outcome: Result<(), &'a Error>,
}

#[expect(
    unused_variables,
    reason = "no-op defaults keep the event name visible in the rendered signature"
)]
pub trait Trace: Send + Sync + 'static {
    fn dns(&self, ev: &Dns<'_>) {}
    fn connect(&self, ev: &Connect<'_>) {}
    fn tls(&self, ev: &Tls<'_>) {}
    fn sent(&self, ev: &Sent<'_>) {}
    fn head(&self, ev: &Head<'_>) {}
    fn done(&self, ev: &Done<'_>) {}
    fn summary(&self, ev: &Summary<'_>) {}
    fn body(&self, ev: &BodyEnd<'_>) {}
}

#[derive(Clone)]
pub(crate) struct Ctx {
    hook: Arc<dyn Trace>,
    id: u64,
    start: Instant,
    root: (u64, Instant),
    notes: notes::SharedNotes,
}

tokio::task_local! {
    static CURRENT: Ctx;
}

static NEXT: AtomicU64 = AtomicU64::new(1);

pub(crate) async fn scope<F>(hook: Option<&Arc<dyn Trace>>, fut: F) -> F::Output
where
    F: Future,
{
    match hook {
        Some(hook) => {
            let id = NEXT.fetch_add(1, Ordering::Relaxed);
            let start = Instant::now();
            let root = CURRENT.try_with(|outer| outer.root).unwrap_or((id, start));
            let ctx = Ctx {
                hook: Arc::clone(hook),
                id,
                start,
                root,
                notes: notes::SharedNotes::default(),
            };
            CURRENT.scope(ctx, fut).await
        }
        None => fut.await,
    }
}

pub(crate) fn carry<F>(fut: F) -> impl Future<Output = F::Output> + Send
where
    F: Future + Send,
{
    let ctx = CURRENT.try_with(Clone::clone).ok();
    async move {
        match ctx {
            Some(ctx) => CURRENT.scope(ctx, fut).await,
            None => fut.await,
        }
    }
}

pub(crate) fn on() -> bool {
    CURRENT.try_with(|_| ()).is_ok()
}

fn with(emit: impl FnOnce(&Ctx)) {
    CURRENT.try_with(emit).unwrap_or(())
}

pub(crate) fn dns(host: &str, port: u16, addrs: usize, elapsed: Duration) {
    with(|ctx| {
        ctx.hook.dns(&Dns {
            id: ctx.id,
            host,
            port,
            addrs,
            elapsed,
        });
    });
}

pub(crate) fn connect(host: &str, port: u16, reused: bool, elapsed: Duration) {
    with(|ctx| {
        ctx.hook.connect(&Connect {
            id: ctx.id,
            host,
            port,
            reused,
            elapsed,
        });
    });
}

pub(crate) fn tls(
    host: &str,
    version: Option<&str>,
    cipher: Option<&str>,
    alpn: Option<&str>,
    elapsed: Duration,
) {
    with(|ctx| {
        ctx.hook.tls(&Tls {
            id: ctx.id,
            host,
            version,
            cipher,
            alpn,
            elapsed,
        });
    });
}

pub(crate) fn sent(host: &str, method: &str, path: &str, protocol: HttpVersion, elapsed: Duration) {
    with(|ctx| {
        ctx.hook.sent(&Sent {
            id: ctx.id,
            host,
            method,
            path,
            protocol,
            elapsed,
        });
    });
}

pub(crate) fn head<I, N, V>(
    host: &str,
    status: u16,
    protocol: HttpVersion,
    elapsed: Duration,
    headers: I,
) where
    I: IntoIterator<Item = (N, V)>,
    N: AsRef<[u8]>,
    V: Into<bytes::Bytes>,
{
    with(|ctx| {
        let headers = crate::core::header_map(headers);
        ctx.hook.head(&Head {
            id: ctx.id,
            host,
            status,
            protocol,
            elapsed,
            headers: &headers,
        });
    });
}

pub(crate) fn done(outcome: Result<(), &Error>) {
    with(|ctx| {
        ctx.hook.done(&Done {
            id: ctx.id,
            elapsed: ctx.start.elapsed(),
            outcome,
        });
    });
}

#[derive(Debug, Default, Clone, Copy)]
pub struct TracingTrace;

impl Trace for TracingTrace {
    fn dns(&self, ev: &Dns<'_>) {
        tracing::debug!(target: "leyline::trace", id = ev.id, host = ev.host, port = ev.port, addrs = ev.addrs, elapsed_ms = ms(ev.elapsed), "dns");
    }

    fn connect(&self, ev: &Connect<'_>) {
        tracing::debug!(target: "leyline::trace", id = ev.id, host = ev.host, port = ev.port, reused = ev.reused, elapsed_ms = ms(ev.elapsed), "connect");
    }

    fn tls(&self, ev: &Tls<'_>) {
        tracing::debug!(target: "leyline::trace", id = ev.id, host = ev.host, version = ev.version, cipher = ev.cipher, alpn = ev.alpn, elapsed_ms = ms(ev.elapsed), "tls");
    }

    fn sent(&self, ev: &Sent<'_>) {
        tracing::debug!(target: "leyline::trace", id = ev.id, host = ev.host, method = ev.method, path = %crate::util::redact_target(ev.path), protocol = ?ev.protocol, elapsed_ms = ms(ev.elapsed), "sent");
    }

    fn head(&self, ev: &Head<'_>) {
        tracing::debug!(target: "leyline::trace", id = ev.id, host = ev.host, status = ev.status, protocol = ?ev.protocol, elapsed_ms = ms(ev.elapsed), "head");
    }

    fn done(&self, ev: &Done<'_>) {
        match ev.outcome {
            Ok(()) => {
                tracing::debug!(target: "leyline::trace", id = ev.id, elapsed_ms = ms(ev.elapsed), "done")
            }
            Err(e) => {
                tracing::debug!(target: "leyline::trace", id = ev.id, elapsed_ms = ms(ev.elapsed), kind = ?e.kind(), error = %e, "done")
            }
        }
    }

    fn summary(&self, ev: &Summary<'_>) {
        summary::render(ev);
    }

    fn body(&self, ev: &BodyEnd<'_>) {
        body::render(ev);
    }
}

fn ms(d: Duration) -> u32 {
    u32::try_from(d.as_millis()).unwrap_or(u32::MAX)
}

impl<T: Trace> Trace for Arc<T> {
    fn dns(&self, ev: &Dns<'_>) {
        T::dns(self, ev);
    }

    fn connect(&self, ev: &Connect<'_>) {
        T::connect(self, ev);
    }

    fn tls(&self, ev: &Tls<'_>) {
        T::tls(self, ev);
    }

    fn sent(&self, ev: &Sent<'_>) {
        T::sent(self, ev);
    }

    fn head(&self, ev: &Head<'_>) {
        T::head(self, ev);
    }

    fn done(&self, ev: &Done<'_>) {
        T::done(self, ev);
    }

    fn summary(&self, ev: &Summary<'_>) {
        T::summary(self, ev);
    }

    fn body(&self, ev: &BodyEnd<'_>) {
        T::body(self, ev);
    }
}

#[cfg(test)]
mod tests;