debugbundle 1.0.0-alpha.1

Safe, bounded runtime error reporting for Rust, Axum and Actix Web
Documentation
#![cfg(feature = "axum")]
mod helpers;
use debugbundle::{Client, Context, ExceptionOccurrence, ExceptionSnapshot, OperationOptions};
use helpers::{configured, Recorder};
use http::{Request, Response};
use http_body_util::{BodyExt, Full};
use std::{
    cell::Cell,
    future::{ready, Future, Ready},
    pin::Pin,
    rc::Rc,
    sync::{Arc, Mutex},
    task::{Context as TaskContext, Poll},
    time::Duration,
};
use tower::{Layer, Service, ServiceExt};
type Bytes = axum::body::Bytes;

struct PendingService {
    ready: Rc<Cell<bool>>,
    context: Arc<Mutex<Option<Context>>>,
    client: Client,
}
impl Service<Request<()>> for PendingService {
    type Response = Response<Full<Bytes>>;
    type Error = std::convert::Infallible;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
    fn poll_ready(&mut self, _: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
        self.ready.set(true);
        Poll::Ready(Ok(()))
    }
    fn call(&mut self, request: Request<()>) -> Self::Future {
        assert!(self.ready.replace(false));
        let context = request.extensions().get::<Context>().unwrap().clone();
        assert!(context.operation_active());
        *self.context.lock().unwrap() = Some(context);
        self.client.capture_exception(
            &ExceptionSnapshot::new("SynchronousCall", "still inside the actual service call"),
            &Context::default(),
        );
        let local = self.ready.clone();
        Box::pin(async move {
            let _local = local;
            std::future::pending().await
        })
    }
}
#[tokio::test]
async fn readiness_uses_same_service_and_cancellation_revokes_without_a_completed_request() {
    let recorder = Arc::new(Recorder::default());
    let client = Client::new(configured(recorder.clone()));
    let observed = Arc::new(Mutex::new(None));
    let mut service =
        debugbundle::tower::DebugBundleLayer::new(client.clone()).layer(PendingService {
            ready: Rc::new(Cell::new(false)),
            context: observed.clone(),
            client: client.clone(),
        });
    service.ready().await.unwrap();
    let future = service.call(Request::new(()));
    assert!(observed
        .lock()
        .unwrap()
        .as_ref()
        .unwrap()
        .operation_active());
    drop(future);
    assert!(!observed
        .lock()
        .unwrap()
        .as_ref()
        .unwrap()
        .operation_active());
    assert!(client.flush_async(Duration::from_secs(1)).await.drained);
    let events = recorder.events.lock().unwrap();
    assert_eq!(events.len(), 1);
    assert_eq!(events[0]["event_type"], "backend_exception");
    assert_eq!(events[0]["service"]["framework"], "tower");
    assert_eq!(events[0]["payload"]["request"]["method"], "GET");
    assert!(events[0]["correlation"]["trace_id"].is_string());
    assert_eq!(events[0]["payload"]["response"]["status_code"], 0);
}

struct BodyError;
impl std::fmt::Debug for BodyError {
    fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        panic!("SDK must not debug-format native errors")
    }
}
impl std::fmt::Display for BodyError {
    fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        panic!("SDK must not format native errors")
    }
}
impl std::error::Error for BodyError {}
struct BrokenBody;
impl http_body::Body for BrokenBody {
    type Data = Bytes;
    type Error = BodyError;
    fn poll_frame(
        self: Pin<&mut Self>,
        _: &mut TaskContext<'_>,
    ) -> Poll<Option<Result<http_body::Frame<Bytes>, BodyError>>> {
        Poll::Ready(Some(Err(BodyError)))
    }
}
struct BrokenService;
impl Service<Request<()>> for BrokenService {
    type Response = Response<BrokenBody>;
    type Error = std::convert::Infallible;
    type Future = Ready<Result<Self::Response, Self::Error>>;
    fn poll_ready(&mut self, _: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }
    fn call(&mut self, _: Request<()>) -> Self::Future {
        ready(Ok(Response::new(BrokenBody)))
    }
}
#[tokio::test]
async fn native_body_error_is_returned_and_captured_without_formatting() {
    let recorder = Arc::new(Recorder::default());
    let client = Client::new(configured(recorder.clone()));
    let response = debugbundle::tower::DebugBundleLayer::new(client.clone())
        .layer(BrokenService)
        .oneshot(Request::new(()))
        .await
        .unwrap();
    assert!(response.into_body().collect().await.is_err());
    assert!(client.flush_async(Duration::from_secs(2)).await.drained);
    let events = recorder.events.lock().unwrap();
    assert_eq!(events.len(), 1);
    assert_eq!(events[0]["event_type"], "backend_exception");
    assert_eq!(events[0]["payload"]["response"]["status_code"], 200);
    assert!(events[0]["payload"]["name"]
        .as_str()
        .unwrap()
        .ends_with("BodyError"));
}

#[test]
fn explicit_outbound_trace_is_first_party_only_and_excludes_sdk_transport() {
    let recorder = Arc::new(Recorder::default());
    let mut config = configured(recorder);
    config.trace_propagation_targets = vec![
        "https://first.example".into(),
        "https://api.debugbundle.com".into(),
    ];
    let client = Client::new(config);
    let trace = "33333333-3333-4333-8333-333333333333";
    let op = client.start_operation(OperationOptions {
        trace_id: Some(trace.into()),
        ..OperationOptions::default()
    });
    assert_eq!(
        client
            .trace_header_for("https://first.example/path", &op.context())
            .as_deref(),
        Some(trace)
    );
    for url in [
        "https://third.example/path",
        "https://first.example.attacker.test/path",
        "https://user:password@first.example/path",
        "https://api.debugbundle.com/v1/events",
        "https://api.debugbundle.com/v1/sdk/config",
    ] {
        assert!(
            client.trace_header_for(url, &op.context()).is_none(),
            "{url}"
        );
    }
}

#[cfg(feature = "axum")]
#[tokio::test]
async fn nested_layers_typed_errors_and_extractor_rejection_preserve_native_responses() {
    use axum::{
        extract::{Extension, Json},
        http::StatusCode,
        routing::{get, post},
        Router,
    };
    use serde_json::Value;
    let recorder = Arc::new(Recorder::default());
    let client = Client::new(configured(recorder.clone()));
    let handler_client = client.clone();
    let app = Router::new()
        .route(
            "/typed/{id}",
            get(move |Extension(context): Extension<Context>| {
                let client = handler_client.clone();
                async move {
                    debugbundle::axum::ErrorResponse::new(
                        (StatusCode::BAD_GATEWAY, "native error body"),
                        client,
                        ExceptionOccurrence::new(&ExceptionSnapshot::new(
                            "TypedFailure",
                            "safe technical message",
                        ))
                        .unwrap(),
                        context,
                    )
                }
            }),
        )
        .route(
            "/json",
            post(|Json(_value): Json<Value>| async { StatusCode::OK }),
        )
        .layer(debugbundle::axum::DebugBundleLayer::new(client.clone()))
        .layer(debugbundle::axum::DebugBundleLayer::new(client.clone()));
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/typed/42?password=SYNTHETIC_SECRET")
                .body(axum::body::Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), 502);
    assert_eq!(
        &axum::body::to_bytes(response.into_body(), 1024)
            .await
            .unwrap()[..],
        b"native error body"
    );
    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/json")
                .header("content-type", "application/json")
                .body(axum::body::Body::from("bad json"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), 400);
    drop(response);
    assert!(client.flush_async(Duration::from_secs(2)).await.drained);
    let events = recorder.events.lock().unwrap();
    assert_eq!(events.len(), 2);
    let error = events
        .iter()
        .find(|event| event["event_type"] == "backend_exception")
        .unwrap();
    assert_eq!(error["payload"]["request"]["method"], "GET");
    assert_eq!(error["payload"]["response"]["status_code"], 502);
    assert_eq!(
        error["payload"]["request"]["query"]["password"],
        "[REDACTED]"
    );
    assert_eq!(
        events
            .iter()
            .filter(|event| event["event_type"] == "request_event")
            .count(),
        1
    );
}