Skip to main content

autumn_web/
reporting.rs

1//! Pluggable error reporting: capture handler panics and 5xx responses and
2//! route them to one or more configured reporters.
3//!
4//! When an Autumn handler panics or returns a server error, the failure is
5//! turned into a structured [`ErrorEvent`] and delivered to every registered
6//! [`ErrorReporter`]. This is the "where do my errors go?" seam: ship events to
7//! Sentry, Honeycomb, Slack, or a custom sink by implementing a single trait
8//! and wiring it once with
9//! [`AppBuilder::with_error_reporter`](crate::app::AppBuilder::with_error_reporter).
10//!
11//! The design mirrors Rails' [Error Reporter] and Autumn's other pluggable
12//! backends ([`BlobStore`](crate::storage::BlobStore),
13//! [`Cache`](crate::cache::Cache)): a built-in [`LogReporter`] (which uses
14//! `tracing`) ships as the default so the feature is useful with zero extra
15//! dependencies, and one builder call swaps in your own sink.
16//!
17//! # What gets reported
18//!
19//! - **Handler panics.** A [`ReportingLayer`] catches unwinding panics at the
20//!   HTTP layer (so a single panicking handler can never abort the worker
21//!   task), converts them into a sanitized [`AutumnError`](crate::AutumnError)
22//!   `500` Problem Details response, and reports an [`ErrorEvent`] carrying the
23//!   panic payload and (when `RUST_BACKTRACE` is set) a backtrace.
24//! - **Server errors.** Any response with a `5xx` status is reported with its
25//!   status, message, and Problem Details type.
26//!
27//! Client (`4xx`) errors are intentionally *not* reported — this slice is
28//! panics + server errors only.
29//!
30//! ## Scope: which 5xx are observed
31//!
32//! The layer is installed inner to
33//! [`RequestIdLayer`](crate::middleware::RequestIdLayer) so every event carries
34//! the request id (and a panic, which unwinds the inner stack, still has it).
35//! A consequence of that placement is that 5xx responses produced by middleware
36//! *outer* to it — most notably a `503` from the session layer when a session
37//! store (e.g. Redis) is unavailable — are not observed here. That is a
38//! deliberate trade-off: such failures are infrastructure outages already
39//! surfaced by readiness/health probes, and moving reporting outside the
40//! session layer would also move it outside `RequestIdLayer`, dropping the
41//! request id from *every* event. Handler panics and handler/inner-middleware
42//! server errors — the failures an app owner is expected to act on — are
43//! reported with full context.
44//!
45//! # Example
46//!
47//! ```rust,no_run
48//! use autumn_web::reporting::{ErrorEvent, ErrorReporter, ReportFuture};
49//!
50//! struct SlackReporter {
51//!     webhook_url: String,
52//! }
53//!
54//! impl ErrorReporter for SlackReporter {
55//!     fn report<'a>(&'a self, event: &'a ErrorEvent) -> ReportFuture<'a> {
56//!         Box::pin(async move {
57//!             // post `event` to Slack, swallow any transport error
58//!             let _ = (&self.webhook_url, event.status);
59//!         })
60//!     }
61//! }
62//!
63//! # #[autumn_web::main]
64//! # async fn main() {
65//! autumn_web::app()
66//!     .with_error_reporter(SlackReporter { webhook_url: "https://hooks.slack.example".into() })
67//! #   .routes(vec![])
68//! #   ;
69//! # }
70//! ```
71//!
72//! [Error Reporter]: https://guides.rubyonrails.org/error_reporting.html
73
74use std::any::Any;
75use std::backtrace::{Backtrace, BacktraceStatus};
76use std::cell::RefCell;
77use std::future::Future;
78use std::panic::AssertUnwindSafe;
79use std::pin::Pin;
80use std::sync::{Arc, Once};
81use std::task::{Context, Poll};
82
83use axum::extract::MatchedPath;
84use axum::http::{Request, StatusCode};
85use axum::response::{IntoResponse, Response};
86use futures::FutureExt;
87use pin_project_lite::pin_project;
88use tower::{Layer, Service};
89
90use crate::middleware::RequestId;
91use crate::middleware::exception_filter::AutumnErrorInfo;
92
93/// The future returned by [`ErrorReporter::report`].
94///
95/// A boxed, pinned future mirroring the shape of
96/// [`BlobFuture`](crate::storage::BlobFuture) so the trait stays object-safe
97/// while remaining async-friendly.
98pub type ReportFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
99
100/// A structured description of a failure worth reporting.
101///
102/// Carries enough request context to locate the failure (route, method,
103/// request id) plus the failure details (status, message, Problem Details
104/// type). For panics, [`panic`](ErrorEvent::panic) carries the payload and an
105/// optional backtrace.
106#[derive(Debug, Clone)]
107#[non_exhaustive]
108pub struct ErrorEvent {
109    /// HTTP status code of the failing response (always `5xx`).
110    pub status: StatusCode,
111    /// Human-readable error message. For panics this is the panic payload; for
112    /// server errors it is the underlying error's message.
113    pub message: String,
114    /// Problem Details `type` URI, when the error carried one.
115    pub problem_type: Option<String>,
116    /// The request id (`X-Request-Id`) of the failing request, when available.
117    pub request_id: Option<String>,
118    /// The matched route template (e.g. `/users/{id}`), when available.
119    pub route: Option<String>,
120    /// The HTTP method of the failing request (e.g. `GET`), when available.
121    pub method: Option<String>,
122    /// Panic details, present only when the failure originated from a caught
123    /// handler panic.
124    pub panic: Option<PanicInfo>,
125}
126
127/// Details of a caught handler panic.
128#[derive(Debug, Clone)]
129#[non_exhaustive]
130pub struct PanicInfo {
131    /// The panic payload, downcast to a string when possible.
132    pub payload: String,
133    /// A captured backtrace, present only when `RUST_BACKTRACE` is set.
134    pub backtrace: Option<String>,
135}
136
137/// A sink for [`ErrorEvent`]s.
138///
139/// Implement this trait to ship unhandled panics and server errors to an
140/// external service. Register implementations with
141/// [`AppBuilder::with_error_reporter`](crate::app::AppBuilder::with_error_reporter);
142/// multiple reporters can be chained and each receives every event.
143///
144/// Reporting runs on a detached task, so [`report`](ErrorReporter::report) does
145/// not block the client response. Any panic raised inside `report` is caught
146/// and logged — a misbehaving reporter never affects the response.
147pub trait ErrorReporter: Send + Sync + 'static {
148    /// Deliver an [`ErrorEvent`] to the sink.
149    ///
150    /// Implementations should swallow their own transport errors; returning is
151    /// the only signal the framework needs.
152    fn report<'a>(&'a self, event: &'a ErrorEvent) -> ReportFuture<'a>;
153}
154
155/// The built-in default reporter: logs every event through `tracing`.
156///
157/// Installed automatically when no other reporter is registered, so error
158/// reporting is useful out of the box with zero extra dependencies.
159#[derive(Debug, Clone, Default)]
160pub struct LogReporter;
161
162impl ErrorReporter for LogReporter {
163    fn report<'a>(&'a self, event: &'a ErrorEvent) -> ReportFuture<'a> {
164        Box::pin(async move {
165            if let Some(panic) = event.panic.as_ref() {
166                tracing::error!(
167                    status = %event.status,
168                    method = event.method.as_deref().unwrap_or("-"),
169                    route = event.route.as_deref().unwrap_or("-"),
170                    request_id = event.request_id.as_deref().unwrap_or("-"),
171                    backtrace = panic.backtrace.as_deref().unwrap_or("(set RUST_BACKTRACE=1 to capture)"),
172                    "handler panic captured: {}",
173                    panic.payload
174                );
175            } else {
176                tracing::error!(
177                    status = %event.status,
178                    method = event.method.as_deref().unwrap_or("-"),
179                    route = event.route.as_deref().unwrap_or("-"),
180                    request_id = event.request_id.as_deref().unwrap_or("-"),
181                    problem_type = event.problem_type.as_deref().unwrap_or("-"),
182                    "server error captured: {}",
183                    event.message
184                );
185            }
186        })
187    }
188}
189
190/// Runtime holder for the registered reporters, installed on
191/// [`AppState`](crate::state::AppState) extensions so the
192/// [`ReportingLayer`] can pick them up at router-build time.
193#[derive(Clone, Default)]
194pub(crate) struct RegisteredReporters(pub(crate) Vec<Arc<dyn ErrorReporter>>);
195
196/// The shared reporter chain plus sampling/enable knobs.
197struct ReporterChain {
198    reporters: Vec<Arc<dyn ErrorReporter>>,
199    enabled: bool,
200    sample_rate: f64,
201}
202
203impl ReporterChain {
204    /// Decide whether to deliver this event, then dispatch it on a detached
205    /// task so reporting never blocks (or breaks) the client response.
206    fn dispatch(self: &Arc<Self>, event: ErrorEvent) {
207        if !self.enabled || !sampled(self.sample_rate) {
208            return;
209        }
210        // Reporting is best-effort: if we're somehow off-runtime, drop it
211        // rather than panic.
212        if let Ok(handle) = tokio::runtime::Handle::try_current() {
213            let chain = Arc::clone(self);
214            handle.spawn(async move {
215                chain.report_all(&event).await;
216            });
217        }
218    }
219
220    async fn report_all(&self, event: &ErrorEvent) {
221        for reporter in &self.reporters {
222            // Guard both future construction and polling: a panicking reporter
223            // must never escape to abort the reporting task.
224            match std::panic::catch_unwind(AssertUnwindSafe(|| reporter.report(event))) {
225                Ok(future) => {
226                    if AssertUnwindSafe(future).catch_unwind().await.is_err() {
227                        tracing::warn!("error reporter panicked while reporting; ignoring");
228                    }
229                }
230                Err(_panic) => {
231                    tracing::warn!("error reporter panicked constructing report future; ignoring");
232                }
233            }
234        }
235    }
236}
237
238thread_local! {
239    /// Per-thread Xorshift64 state, seeded once from the OS entropy source.
240    /// Sampling does not need cryptographic randomness, so a userspace PRNG
241    /// keeps the hot path (every panic / 5xx) free of `getrandom` syscalls.
242    static RNG_STATE: std::cell::Cell<u64> = std::cell::Cell::new(seed_rng());
243}
244
245/// Seed the per-thread PRNG from the OS entropy source, falling back to a
246/// non-zero constant if that ever fails (Xorshift must never start at zero).
247fn seed_rng() -> u64 {
248    let mut buf = [0u8; 8];
249    if getrandom::getrandom(&mut buf).is_ok() {
250        let seed = u64::from_ne_bytes(buf);
251        if seed != 0 {
252            return seed;
253        }
254    }
255    0x5555_5555_5555_5555
256}
257
258/// Draw a fast, non-cryptographic `u64` from the per-thread Xorshift64 PRNG.
259fn next_u64() -> u64 {
260    RNG_STATE.with(|cell| {
261        let mut x = cell.get();
262        x ^= x << 13;
263        x ^= x >> 7;
264        x ^= x << 17;
265        cell.set(x);
266        x
267    })
268}
269
270/// Draw a sampling decision for the given rate in `[0.0, 1.0]`.
271///
272/// The cast precision loss is irrelevant here: sampling tolerates a fuzzy
273/// boundary, and a 53-bit draw is more than enough resolution for a rate knob.
274#[allow(clippy::cast_precision_loss)]
275fn sampled(rate: f64) -> bool {
276    if rate >= 1.0 {
277        return true;
278    }
279    if rate <= 0.0 {
280        return false;
281    }
282    // Mask to 53 bits so the value converts to f64 without rounding.
283    let draw = next_u64() >> 11;
284    let value = draw as f64 / (1u64 << 53) as f64;
285    value < rate
286}
287
288// ── Panic backtrace capture ─────────────────────────────────────────────────
289
290thread_local! {
291    static LAST_PANIC: RefCell<Option<CapturedPanic>> = const { RefCell::new(None) };
292}
293
294struct CapturedPanic {
295    backtrace: Option<String>,
296}
297
298static HOOK_INSTALLED: Once = Once::new();
299
300/// Install a panic hook (once) that records a backtrace for the panicking
301/// thread so the [`ReportingLayer`] can attach it to the [`ErrorEvent`] after
302/// `catch_unwind` returns. The previous hook is preserved and still runs, so
303/// the default panic logging behavior is unchanged.
304fn ensure_panic_hook() {
305    HOOK_INSTALLED.call_once(|| {
306        let previous = std::panic::take_hook();
307        std::panic::set_hook(Box::new(move |info| {
308            // `Backtrace::capture()` only captures when `RUST_BACKTRACE` is set,
309            // so this is free when backtraces are disabled.
310            let backtrace = Backtrace::capture();
311            let backtrace =
312                (backtrace.status() == BacktraceStatus::Captured).then(|| backtrace.to_string());
313            LAST_PANIC.with(|cell| {
314                *cell.borrow_mut() = Some(CapturedPanic { backtrace });
315            });
316            previous(info);
317        }));
318    });
319}
320
321/// Downcast a panic payload to a string, mirroring the formatting used for
322/// repository commit hook panics.
323fn format_panic_payload(payload: &(dyn Any + Send)) -> String {
324    payload
325        .downcast_ref::<&str>()
326        .map(|s| (*s).to_owned())
327        .or_else(|| payload.downcast_ref::<String>().cloned())
328        .unwrap_or_else(|| "handler panicked".to_owned())
329}
330
331// ── Tower layer ──────────────────────────────────────────────────────────────
332
333/// Per-request context captured before the inner service runs, so it is still
334/// available if the handler panics.
335#[derive(Clone)]
336struct RequestContext {
337    method: String,
338    route: Option<String>,
339    request_id: Option<String>,
340}
341
342/// Tower [`Layer`] that catches handler panics and reports panics + 5xx
343/// responses to the registered [`ErrorReporter`]s.
344///
345/// Applied automatically by the framework inner to
346/// [`RequestIdLayer`](crate::middleware::RequestIdLayer) (so the request id is
347/// available) and outer to the route handler (so handler panics are caught).
348#[derive(Clone)]
349pub struct ReportingLayer {
350    chain: Arc<ReporterChain>,
351}
352
353impl ReportingLayer {
354    /// Build a reporting layer from the registered reporters and config knobs.
355    ///
356    /// When `reporters` is empty, the built-in [`LogReporter`] is installed so
357    /// panics and server errors are still surfaced.
358    #[must_use]
359    pub(crate) fn new(
360        reporters: Vec<Arc<dyn ErrorReporter>>,
361        enabled: bool,
362        sample_rate: f64,
363    ) -> Self {
364        ensure_panic_hook();
365        let reporters = if reporters.is_empty() {
366            vec![Arc::new(LogReporter) as Arc<dyn ErrorReporter>]
367        } else {
368            reporters
369        };
370        Self {
371            chain: Arc::new(ReporterChain {
372                reporters,
373                enabled,
374                sample_rate,
375            }),
376        }
377    }
378}
379
380impl<S> Layer<S> for ReportingLayer {
381    type Service = ReportingService<S>;
382
383    fn layer(&self, inner: S) -> Self::Service {
384        ReportingService {
385            inner,
386            chain: Arc::clone(&self.chain),
387        }
388    }
389}
390
391/// Tower [`Service`] produced by [`ReportingLayer`].
392#[derive(Clone)]
393pub struct ReportingService<S> {
394    inner: S,
395    chain: Arc<ReporterChain>,
396}
397
398impl<S, ReqBody> Service<Request<ReqBody>> for ReportingService<S>
399where
400    S: Service<Request<ReqBody>, Response = Response>,
401{
402    type Response = Response;
403    type Error = S::Error;
404    type Future = ReportingFuture<S::Future>;
405
406    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
407        self.inner.poll_ready(cx)
408    }
409
410    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
411        let method = req.method().as_str().to_owned();
412        let route = req
413            .extensions()
414            .get::<MatchedPath>()
415            .map(|m| m.as_str().to_owned());
416        let request_id = req
417            .extensions()
418            .get::<RequestId>()
419            .map(std::string::ToString::to_string);
420        let context = Some(RequestContext {
421            method,
422            route,
423            request_id,
424        });
425
426        // Catch panics raised synchronously while the inner service constructs
427        // its future (e.g. a handler closure or user Tower layer that panics in
428        // `call` before returning a future), not just panics raised while
429        // polling. Mirrors `tower_http::catch_panic`.
430        let inner = &mut self.inner;
431        match std::panic::catch_unwind(AssertUnwindSafe(|| inner.call(req))) {
432            Ok(future) => ReportingFuture {
433                inner: Some(future),
434                pending_panic: None,
435                context,
436                chain: Arc::clone(&self.chain),
437            },
438            Err(panic) => ReportingFuture {
439                inner: None,
440                pending_panic: Some(panic),
441                context,
442                chain: Arc::clone(&self.chain),
443            },
444        }
445    }
446}
447
448pin_project! {
449    /// Future that catches panics from the inner service and dispatches error
450    /// events for panics and 5xx responses.
451    pub struct ReportingFuture<F> {
452        #[pin]
453        inner: Option<F>,
454        // A panic captured from the inner service's `call`, surfaced on the
455        // first poll. `inner` is `None` exactly when this is `Some`.
456        pending_panic: Option<Box<dyn Any + Send>>,
457        context: Option<RequestContext>,
458        chain: Arc<ReporterChain>,
459    }
460}
461
462impl<F, E> Future for ReportingFuture<F>
463where
464    F: Future<Output = Result<Response, E>>,
465{
466    type Output = Result<Response, E>;
467
468    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
469        let this = self.project();
470
471        // A panic captured in `call` is surfaced as a sanitized 500 here.
472        if let Some(panic) = this.pending_panic.take() {
473            let context = this.context.take();
474            return Poll::Ready(Ok(handle_panic(&*panic, context, this.chain)));
475        }
476
477        let Some(inner) = this.inner.as_pin_mut() else {
478            // Already resolved a panic on a prior poll; nothing left to do.
479            return Poll::Pending;
480        };
481
482        // Catch a panic raised while polling the handler future. Wrapping the
483        // poll keeps a panicking handler from aborting the worker task.
484        match std::panic::catch_unwind(AssertUnwindSafe(move || inner.poll(cx))) {
485            Ok(Poll::Pending) => Poll::Pending,
486            Ok(Poll::Ready(Ok(response))) => {
487                if let Some(context) = this.context.take() {
488                    report_response(&response, context, this.chain);
489                }
490                Poll::Ready(Ok(response))
491            }
492            Ok(Poll::Ready(Err(error))) => Poll::Ready(Err(error)),
493            Err(panic) => {
494                let context = this.context.take();
495                let response = handle_panic(&*panic, context, this.chain);
496                Poll::Ready(Ok(response))
497            }
498        }
499    }
500}
501
502/// Report a completed response when it is a server error.
503fn report_response(response: &Response, context: RequestContext, chain: &Arc<ReporterChain>) {
504    if !response.status().is_server_error() {
505        return;
506    }
507    let info = response.extensions().get::<AutumnErrorInfo>();
508    let (message, problem_type) = info.map_or_else(
509        || {
510            (
511                response
512                    .status()
513                    .canonical_reason()
514                    .unwrap_or("server error")
515                    .to_owned(),
516                None,
517            )
518        },
519        |info| (info.message.clone(), info.problem_type.map(str::to_owned)),
520    );
521
522    chain.dispatch(ErrorEvent {
523        status: response.status(),
524        message,
525        problem_type,
526        request_id: context.request_id,
527        route: context.route,
528        method: Some(context.method),
529        panic: None,
530    });
531}
532
533/// Convert a caught panic into a sanitized 500 response and report it.
534fn handle_panic(
535    payload: &(dyn Any + Send),
536    context: Option<RequestContext>,
537    chain: &Arc<ReporterChain>,
538) -> Response {
539    let message = format_panic_payload(payload);
540    let backtrace = LAST_PANIC
541        .with(|cell| cell.borrow_mut().take())
542        .and_then(|captured| captured.backtrace);
543
544    if let Some(context) = context {
545        chain.dispatch(ErrorEvent {
546            status: StatusCode::INTERNAL_SERVER_ERROR,
547            message: message.clone(),
548            problem_type: None,
549            request_id: context.request_id,
550            route: context.route,
551            method: Some(context.method),
552            panic: Some(PanicInfo {
553                payload: message,
554                backtrace,
555            }),
556        });
557    }
558
559    // The client gets a clean, sanitized Problem Details 500 — the panic
560    // payload only ever reaches the reporter, never the wire. The
561    // `AutumnErrorInfo` stashed by `into_response` lets the exception-filter
562    // chain negotiate HTML error pages as usual.
563    crate::error::AutumnError::internal_server_error_msg("Internal server error").into_response()
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use std::sync::Mutex;
570
571    #[test]
572    fn sampled_extremes_are_deterministic() {
573        assert!(sampled(1.0));
574        assert!(sampled(2.0));
575        assert!(!sampled(0.0));
576        assert!(!sampled(-1.0));
577    }
578
579    #[test]
580    fn sampled_full_rate_always_true_over_many_draws() {
581        for _ in 0..1000 {
582            assert!(sampled(1.0));
583        }
584    }
585
586    #[test]
587    fn format_panic_payload_handles_str_and_string() {
588        let s: &str = "boom";
589        assert_eq!(format_panic_payload(&s), "boom");
590        let owned: String = "kaboom".to_owned();
591        assert_eq!(format_panic_payload(&owned), "kaboom");
592        let other: u32 = 7;
593        assert_eq!(format_panic_payload(&other), "handler panicked");
594    }
595
596    #[test]
597    fn log_reporter_is_the_default_when_empty() {
598        let layer = ReportingLayer::new(Vec::new(), true, 1.0);
599        assert_eq!(layer.chain.reporters.len(), 1);
600    }
601
602    #[tokio::test]
603    async fn panic_in_inner_call_is_caught_as_500() {
604        use axum::body::Body;
605        use std::convert::Infallible;
606        use tower::ServiceExt;
607
608        // An inner service that panics synchronously in `call`, before ever
609        // returning a future — the case poll-only catch_unwind would miss.
610        #[derive(Clone)]
611        struct PanicInCall;
612        impl Service<Request<Body>> for PanicInCall {
613            type Response = Response;
614            type Error = Infallible;
615            type Future = std::future::Ready<Result<Response, Infallible>>;
616
617            fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
618                Poll::Ready(Ok(()))
619            }
620
621            fn call(&mut self, _req: Request<Body>) -> Self::Future {
622                panic!("boom in call");
623            }
624        }
625
626        let service = ReportingLayer::new(Vec::new(), true, 1.0).layer(PanicInCall);
627        let response = service
628            .oneshot(Request::new(Body::empty()))
629            .await
630            .expect("panic in call must be converted to a response, not propagated");
631        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
632    }
633
634    #[tokio::test]
635    async fn disabled_chain_does_not_dispatch() {
636        #[derive(Clone)]
637        struct Counter(Arc<Mutex<u32>>);
638        impl ErrorReporter for Counter {
639            fn report<'a>(&'a self, _event: &'a ErrorEvent) -> ReportFuture<'a> {
640                let count = self.0.clone();
641                Box::pin(async move {
642                    *count.lock().unwrap() += 1;
643                })
644            }
645        }
646
647        let count = Arc::new(Mutex::new(0));
648        let chain = Arc::new(ReporterChain {
649            reporters: vec![Arc::new(Counter(count.clone()))],
650            enabled: false,
651            sample_rate: 1.0,
652        });
653        chain.dispatch(ErrorEvent {
654            status: StatusCode::INTERNAL_SERVER_ERROR,
655            message: "x".into(),
656            problem_type: None,
657            request_id: None,
658            route: None,
659            method: None,
660            panic: None,
661        });
662        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
663        assert_eq!(*count.lock().unwrap(), 0);
664    }
665
666    fn server_error_event() -> ErrorEvent {
667        ErrorEvent {
668            status: StatusCode::INTERNAL_SERVER_ERROR,
669            message: "boom".into(),
670            problem_type: Some("https://autumn.dev/problems/x".into()),
671            request_id: Some("req-1".into()),
672            route: Some("/x".into()),
673            method: Some("GET".into()),
674            panic: None,
675        }
676    }
677
678    fn panic_event() -> ErrorEvent {
679        ErrorEvent {
680            status: StatusCode::INTERNAL_SERVER_ERROR,
681            message: "kaboom".into(),
682            problem_type: None,
683            request_id: None,
684            route: None,
685            method: None,
686            panic: Some(PanicInfo {
687                payload: "kaboom".into(),
688                backtrace: Some("<backtrace>".into()),
689            }),
690        }
691    }
692
693    #[tokio::test]
694    async fn log_reporter_reports_both_event_kinds() {
695        // Exercises both branches of the default reporter, including the
696        // `unwrap_or` fallbacks for absent context.
697        let reporter = LogReporter;
698        reporter.report(&server_error_event()).await;
699        reporter.report(&panic_event()).await;
700    }
701
702    #[test]
703    fn sampled_fractional_uses_prng_and_varies() {
704        // Drives the thread-local Xorshift PRNG path (seed + draws + f64 math)
705        // that the rate-1.0 short-circuit never reaches.
706        let mut trues = 0;
707        for _ in 0..10_000 {
708            if sampled(0.5) {
709                trues += 1;
710            }
711        }
712        assert!(
713            trues > 0 && trues < 10_000,
714            "fractional sampling should produce a mix of decisions, got {trues}"
715        );
716    }
717
718    #[tokio::test]
719    async fn reporter_panicking_while_constructing_future_is_swallowed() {
720        // A reporter whose `report` method panics *before* returning a future
721        // exercises the `Err` arm of `report_all` (distinct from a future that
722        // panics when polled).
723        struct PanicOnConstruct;
724        impl ErrorReporter for PanicOnConstruct {
725            fn report<'a>(&'a self, _event: &'a ErrorEvent) -> ReportFuture<'a> {
726                panic!("panic before returning the future");
727            }
728        }
729
730        let chain = ReporterChain {
731            reporters: vec![Arc::new(PanicOnConstruct)],
732            enabled: true,
733            sample_rate: 1.0,
734        };
735        // Must complete without unwinding.
736        chain.report_all(&server_error_event()).await;
737    }
738
739    #[test]
740    fn dispatch_without_a_runtime_is_a_noop() {
741        // A plain `#[test]` has no current tokio runtime, so dispatch should
742        // take the best-effort early return rather than panic on spawn.
743        let chain = Arc::new(ReporterChain {
744            reporters: vec![Arc::new(LogReporter)],
745            enabled: true,
746            sample_rate: 1.0,
747        });
748        chain.dispatch(server_error_event());
749    }
750}