debugbundle 1.0.0-alpha.1

Safe, bounded runtime error reporting for Rust, Axum and Actix Web
Documentation
mod helpers;
use debugbundle::{
    delivery::{Transport, TransportFailure, TransportResponse},
    Client, Config, Context, ExceptionSnapshot, Level,
};
use helpers::{configured, Recorder};
use serde_json::{json, Value};
use std::{
    sync::{
        atomic::{AtomicUsize, Ordering},
        mpsc, Arc, Condvar, Mutex,
    },
    time::{Duration, Instant},
};

#[test]
fn owner_replaced_mathematical_integer_status_keeps_failure_policy() {
    let recorder = Arc::new(Recorder::default());
    let mut config = configured(recorder.clone());
    config.before_send = Some(Arc::new(|mut event| {
        event["payload"]["response_status"] = json!(500.0);
        Some(event)
    }));
    let client = Client::new(config);
    client.capture_request(
        &debugbundle::RequestSnapshot {
            method: "GET".into(),
            path: "/synthetic".into(),
            status: 500,
            ..debugbundle::RequestSnapshot::default()
        },
        &Context::default(),
    );
    assert!(client.flush(Duration::from_secs(2)).drained);
    assert_eq!(recorder.events.lock().unwrap().len(), 1);
}

#[test]
fn blocked_hooks_do_not_block_capture_or_finite_flush_and_keep_ownership_charged() {
    let recorder = Arc::new(Recorder::default());
    let gate = Arc::new((Mutex::new(false), Condvar::new()));
    let held = gate.clone();
    let (tx, rx) = mpsc::sync_channel(1);
    let mut config = configured(recorder.clone());
    config.before_send = Some(Arc::new(move |event| {
        let _ = tx.send(());
        let (lock, changed) = &*held;
        let mut ready = lock.lock().unwrap();
        while !*ready {
            ready = changed.wait(ready).unwrap();
        }
        Some(event)
    }));
    let client = Client::new(config);
    client.capture_log(Level::Error, "held", &Context::default());
    let flushing = client.clone();
    let background = std::thread::spawn(move || flushing.flush(Duration::from_secs(2)));
    rx.recv_timeout(Duration::from_secs(1)).unwrap();
    let start = Instant::now();
    let outcome = client.flush(Duration::from_millis(20));
    assert!(outcome.timed_out);
    assert!(start.elapsed() < Duration::from_secs(1));
    assert_eq!(client.status().buffered_events, 1);
    assert!(client.status().buffered_bytes > 0);
    let start = Instant::now();
    client.capture_log(Level::Error, "second", &Context::default());
    assert!(start.elapsed() < Duration::from_millis(250));
    *gate.0.lock().unwrap() = true;
    gate.1.notify_all();
    assert!(background.join().unwrap().drained);
    assert!(client.flush(Duration::from_secs(2)).drained);
    assert_eq!(recorder.events.lock().unwrap().len(), 2);
}

#[test]
fn valid_replacements_over_budget_are_withheld_and_never_restore_original_content() {
    let recorder = Arc::new(Recorder::default());
    let mut config = configured(recorder.clone());
    config.before_send = Some(Arc::new(|mut event| {
        event["context"] = json!({"large":vec!["x".repeat(16384);17]});
        Some(event)
    }));
    let client = Client::new(config);
    client.capture_exception(
        &ExceptionSnapshot::new(
            "MustNotRestore",
            "original private content removed by owner hook",
        ),
        &Context::default(),
    );
    assert!(client.flush(Duration::from_secs(2)).drained);
    assert!(recorder.events.lock().unwrap().is_empty());
    assert!(client.last_event_at().is_none());
}

#[test]
fn invalid_and_panicking_hooks_use_the_safe_original_and_drop_is_explicit() {
    for mode in 0..3 {
        let recorder = Arc::new(Recorder::default());
        let mut config = configured(recorder.clone());
        config.before_send = Some(Arc::new(move |_| match mode {
            0 => Some(json!(false)),
            1 => panic!("synthetic callback failure"),
            _ => None,
        }));
        let client = Client::new(config);
        client.capture_log(
            Level::Error,
            "password=SYNTHETIC_SECRET",
            &Context::default(),
        );
        assert!(client.flush(Duration::from_secs(2)).drained);
        let events = recorder.events.lock().unwrap();
        if mode == 2 {
            assert!(events.is_empty());
        } else {
            assert_eq!(events[0]["payload"]["message"], "password=[REDACTED]");
        }
    }
}

struct Partial {
    batches: Mutex<Vec<Vec<String>>>,
}
impl Transport for Partial {
    fn send(&self, body: &[u8], _: Duration) -> Result<TransportResponse, TransportFailure> {
        let value: Value = serde_json::from_slice(body).unwrap();
        let ids = value["events"]
            .as_array()
            .unwrap()
            .iter()
            .map(|e| e["event_id"].as_str().unwrap().to_owned())
            .collect::<Vec<_>>();
        let mut batches = self.batches.lock().unwrap();
        let first = batches.is_empty();
        let count = ids.len();
        batches.push(ids);
        Ok(TransportResponse {
            status: 202,
            body: Some(if first {
                json!({"accepted":1,"rejected":2,"errors":[{"index":1,"reason":"monthly_quota_exceeded"},{"index":2,"reason":"capture_policy_rejected"}]})
            } else {
                json!({"accepted":count,"rejected":0,"errors":[]})
            }),
            retry_after: Some(Duration::from_millis(1)),
        })
    }
}
#[test]
fn partial_acknowledgement_retries_only_the_retryable_index() {
    let transport = Arc::new(Partial {
        batches: Mutex::new(Vec::new()),
    });
    let config = Config {
        transport: Some(transport.clone()),
        batch_size: 100,
        ..Config::new("synthetic", "checkout")
    };
    let client = Client::new(config);
    for n in 0..3 {
        client.capture_log(Level::Error, &format!("failure {n}"), &Context::default());
    }
    assert!(client.flush(Duration::from_secs(2)).drained);
    let batches = transport.batches.lock().unwrap();
    assert_eq!(batches.len(), 2);
    assert_eq!(batches[0].len(), 3);
    assert_eq!(batches[1], vec![batches[0][1].clone()]);
}

struct SizedRecorder {
    sizes: Mutex<Vec<usize>>,
    events: Mutex<Vec<String>>,
    hooks: AtomicUsize,
}
impl Transport for SizedRecorder {
    fn send(&self, body: &[u8], _: Duration) -> Result<TransportResponse, TransportFailure> {
        self.sizes.lock().unwrap().push(body.len());
        let value: Value = serde_json::from_slice(body).unwrap();
        let events = value["events"].as_array().unwrap();
        self.events.lock().unwrap().extend(
            events
                .iter()
                .map(|e| e["event_id"].as_str().unwrap().into()),
        );
        Ok(TransportResponse {
            status: 202,
            body: Some(json!({"accepted":events.len(),"rejected":0,"errors":[]})),
            retry_after: None,
        })
    }
}
#[test]
fn final_byte_chunks_preserve_all_identities_and_do_not_repeat_hooks() {
    let transport = Arc::new(SizedRecorder {
        sizes: Mutex::new(Vec::new()),
        events: Mutex::new(Vec::new()),
        hooks: AtomicUsize::new(0),
    });
    let observed = transport.clone();
    let config = Config {
        transport: Some(transport.clone()),
        batch_size: 100,
        before_send: Some(Arc::new(move |event| {
            observed.hooks.fetch_add(1, Ordering::Relaxed);
            Some(event)
        })),
        ..Config::new("synthetic", "checkout")
    };
    let client = Client::new(config);
    for n in 0..30 {
        client.capture_log(
            Level::Error,
            &format!("{n}: {}", "é".repeat(5000)),
            &Context::default(),
        );
    }
    assert!(client.flush(Duration::from_secs(10)).drained);
    let sizes = transport.sizes.lock().unwrap();
    assert!(sizes.len() >= 2);
    assert!(sizes.iter().all(|n| *n <= 262144));
    let events = transport.events.lock().unwrap();
    assert_eq!(events.len(), 30);
    assert_eq!(
        events
            .iter()
            .collect::<std::collections::HashSet<_>>()
            .len(),
        30
    );
    assert_eq!(transport.hooks.load(Ordering::Relaxed), 30);
}

#[test]
fn queue_pressure_has_one_bounded_operational_summary_when_capacity_returns() {
    let recorder = Arc::new(Recorder::default());
    let mut config = configured(recorder.clone());
    config.max_buffered_events = 1;
    let client = Client::new(config);
    client.capture_log(Level::Error, "primary", &Context::default());
    for _ in 0..100 {
        client.capture_log(Level::Error, "rejected", &Context::default());
    }
    assert!(client.flush(Duration::from_secs(2)).drained);
    let events = recorder.events.lock().unwrap();
    let summaries: Vec<_> = events
        .iter()
        .filter(|e| {
            e["event_type"] == "error_suppressed" && e["payload"]["reason"] == "queue_pressure"
        })
        .collect();
    assert_eq!(summaries.len(), 1);
    assert_eq!(summaries[0]["payload"]["suppressed_count"], 100);
}

#[test]
fn optional_evidence_cannot_displace_an_otherwise_admissible_primary() {
    let recorder = Arc::new(Recorder::default());
    let config = configured(recorder.clone());
    let client = Client::new(config);
    let context = Context::new(&json!({"technical":vec!["x".repeat(15000);16]}));
    let snapshot = ExceptionSnapshot::new("Primary", &"failure".repeat(285)).with_stack(
        &"x".repeat(16000),
        debugbundle::TraceOrigin::RuntimeReported,
    );
    client.capture_exception(&snapshot, &context);
    assert!(client.flush(Duration::from_secs(3)).drained);
    let events = recorder.events.lock().unwrap();
    assert_eq!(events.len(), 1);
    assert_eq!(events[0]["payload"]["name"], "Primary");
}

struct OpaquePanic(Arc<AtomicUsize>);
impl Drop for OpaquePanic {
    fn drop(&mut self) {
        self.0.fetch_add(1, Ordering::SeqCst);
        panic!("synthetic payload destructor failure");
    }
}
#[test]
fn opaque_callback_panics_are_discarded_without_bypassing_future_hooks() {
    let recorder = Arc::new(Recorder::default());
    let dropped = Arc::new(AtomicUsize::new(0));
    let observed = dropped.clone();
    let mut config = configured(recorder.clone());
    config.before_send = Some(Arc::new(move |_| {
        std::panic::panic_any(OpaquePanic(observed.clone()))
    }));
    let client = Client::new(config);
    client.capture_log(Level::Error, "first", &Context::default());
    assert!(client.flush(Duration::from_secs(2)).drained);
    assert_eq!(dropped.load(Ordering::SeqCst), 1);
    client.capture_log(
        Level::Error,
        "must not bypass owner hook",
        &Context::default(),
    );
    assert!(client.flush(Duration::from_secs(2)).drained);
    assert!(recorder.events.lock().unwrap().is_empty());
    assert!(client.last_event_at().is_none());
}

#[test]
fn callbacks_cannot_recursively_capture_or_wait_for_their_own_flush() {
    let recorder = Arc::new(Recorder::default());
    let holder = Arc::new(Mutex::new(None::<Client>));
    let nested = holder.clone();
    let calls = Arc::new(AtomicUsize::new(0));
    let observed = calls.clone();
    let mut config = configured(recorder.clone());
    config.before_send = Some(Arc::new(move |event| {
        observed.fetch_add(1, Ordering::SeqCst);
        if let Some(client) = nested.lock().unwrap().as_ref() {
            client.capture_log(Level::Error, "recursive", &Context::default());
            let start = Instant::now();
            assert!(client.flush(Duration::from_secs(20)).timed_out);
            assert!(start.elapsed() < Duration::from_millis(250));
        }
        Some(event)
    }));
    let client = Client::new(config);
    *holder.lock().unwrap() = Some(client.clone());
    client.capture_log(Level::Error, "original", &Context::default());
    assert!(client.flush(Duration::from_secs(2)).drained);
    holder.lock().unwrap().take();
    assert_eq!(calls.load(Ordering::SeqCst), 1);
    assert_eq!(recorder.events.lock().unwrap().len(), 1);
}

#[test]
fn oversized_optional_probe_fields_preserve_the_valid_owner_replacement() {
    let recorder = Arc::new(Recorder::default());
    let mut config = configured(recorder.clone());
    config.before_send = Some(Arc::new(|mut event| {
        event["payload"]["message"] = json!("owner replacement");
        let data = (0..257)
            .map(|index| (format!("technical_{index}"), json!(true)))
            .collect::<serde_json::Map<_, _>>();
        event["payload"]["probe_data"] = json!({"version":1,"items":[{"label":"technical","data":data,"timestamp":chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis,true),"activation_id":null}]});
        Some(event)
    }));
    let client = Client::new(config);
    client.capture_exception(
        &ExceptionSnapshot::new("Primary", "original"),
        &Context::default(),
    );
    assert!(client.flush(Duration::from_secs(2)).drained);
    let events = recorder.events.lock().unwrap();
    assert_eq!(events.len(), 1);
    assert_eq!(events[0]["payload"]["message"], "owner replacement");
    assert!(events[0]["payload"].get("probe_data").is_none());
    assert_eq!(
        events[0]["context"]["probe_limits"]["reason"],
        "budget_exceeded"
    );
}

#[test]
fn optional_evidence_yields_to_actual_escaped_primary_bytes() {
    let context = Context::new(&json!({"technical":"\u{0001}".repeat(500)}));
    let snapshot = ExceptionSnapshot::new("Primary", "owner protected primary").with_stack(
        &"frame\n".repeat(300),
        debugbundle::TraceOrigin::RuntimeReported,
    );
    let baseline = Arc::new(Recorder::default());
    let mut config = configured(baseline.clone());
    config.capture_exception_evidence = false;
    let client = Client::new(config);
    client.capture_exception(&snapshot, &context);
    assert!(client.flush(Duration::from_secs(2)).drained);
    let primary = serde_json::to_vec(&baseline.events.lock().unwrap()[0])
        .unwrap()
        .len();
    drop(client);
    let recorder = Arc::new(Recorder::default());
    let mut config = configured(recorder.clone());
    config.max_buffered_bytes = primary + 256;
    config.before_send = Some(Arc::new(|mut event| {
        event["payload"]["message"] = json!("owner protected primary");
        Some(event)
    }));
    let client = Client::new(config);
    client.capture_exception(&snapshot, &context);
    assert!(client.flush(Duration::from_secs(2)).drained);
    let events = recorder.events.lock().unwrap();
    assert_eq!(events.len(), 1);
    assert_eq!(events[0]["payload"]["message"], "owner protected primary");
    assert!(serde_json::to_vec(&events[0]).unwrap().len() <= primary + 256);
}