Skip to main content

renox_core/
report.rs

1//! Error reports: every error a person should look at (a 500, a job that
2//! failed for good, a scheduled task that failed), handed to the app's
3//! reporters, e.g. to send them to Sentry or a chat channel.
4//!
5//! ```
6//! # use renox::prelude::*;
7//! use renox::report::ErrorReport;
8//!
9//! # let _ =
10//! App::new().report(|report: ErrorReport, state: AppState| async move {
11//!     // e.g. POST it to your error tracker with state.http.
12//!     let _ = state
13//!         .http
14//!         .post("https://errors.example.com/api/events")
15//!         .json(&report)
16//!         .send()
17//!         .await;
18//! })
19//! # ;
20//! ```
21//!
22//! Reporters run in the background, after the response is sent; one that
23//! fails or panics doesn't affect the others. Errors are logged too, with or
24//! without reporters.
25
26use std::future::Future;
27use std::pin::Pin;
28use std::sync::Arc;
29
30use serde::Serialize;
31
32use crate::AppState;
33
34/// Where an error happened.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "snake_case")]
37#[non_exhaustive]
38pub enum ReportKind {
39    /// A request answered 500.
40    Request,
41    /// A job failed for good (attempts used up, or a permanent error).
42    Job,
43    /// A scheduled task failed or panicked.
44    ScheduledTask,
45}
46
47/// The request an error came from.
48#[derive(Debug, Clone, Serialize)]
49#[non_exhaustive]
50pub struct RequestReport {
51    /// The HTTP method, e.g. `POST`.
52    pub method: String,
53    /// The URL path, without the query string.
54    pub path: String,
55    /// The request id, also in the logs and the response's `X-Request-Id`.
56    pub id: String,
57    /// The client's IP address (`ClientIp`), if known.
58    pub ip: Option<String>,
59    /// The logged-in user, if any.
60    pub user_id: Option<i64>,
61}
62
63/// One error, for [`App::report`](crate::App::report).
64#[derive(Debug, Clone, Serialize)]
65#[non_exhaustive]
66pub struct ErrorReport {
67    /// Where it happened: a request, a job or a scheduled task.
68    pub kind: ReportKind,
69    /// The error's own message.
70    pub message: String,
71    /// The error with its causes (`{:?}`), for the developer.
72    pub details: String,
73    /// The scheduled task's name, or the job's name and id (`send-invoice #42`).
74    pub source: Option<String>,
75    /// The request, for [`ReportKind::Request`].
76    pub request: Option<RequestReport>,
77    /// `APP_ENV`: `local`, `testing` or `production`.
78    pub environment: String,
79    /// Unix seconds.
80    pub at: i64,
81}
82
83pub(crate) type ReportFn =
84    Arc<dyn Fn(ErrorReport, AppState) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
85
86pub(crate) fn report_fn<F, Fut>(reporter: F) -> ReportFn
87where
88    F: Fn(ErrorReport, AppState) -> Fut + Send + Sync + 'static,
89    Fut: Future<Output = ()> + Send + 'static,
90{
91    Arc::new(move |report, state| Box::pin(reporter(report, state)))
92}
93
94impl ErrorReport {
95    pub(crate) fn new(
96        state: &AppState,
97        kind: ReportKind,
98        message: String,
99        details: String,
100        source: Option<String>,
101    ) -> Self {
102        let request =
103            crate::context::get::<crate::context::RequestInfo>().map(|info| RequestReport {
104                method: info.method,
105                path: info.path,
106                id: info.id,
107                ip: info.ip,
108                user_id: crate::auth::current_user_id(),
109            });
110        Self {
111            kind,
112            message,
113            details,
114            source,
115            request,
116            environment: format!("{:?}", state.config.env).to_lowercase(),
117            at: crate::clock::unix_secs(),
118        }
119    }
120}
121
122/// Hands `report` to every reporter, in the background.
123pub(crate) fn send(state: &AppState, report: ErrorReport) {
124    if state.reporters.is_empty() {
125        return;
126    }
127    for reporter in state.reporters.iter() {
128        let (reporter, report, state) = (reporter.clone(), report.clone(), state.clone());
129        let run = async move {
130            let task = tokio::spawn(crate::context::scope_app(
131                state.clone(),
132                reporter(report, state),
133            ));
134            if task.await.is_err() {
135                tracing::error!("an error reporter panicked");
136            }
137        };
138        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
139            runtime.spawn(run);
140        }
141    }
142}
143
144/// A report for a 500 in the current request, when there's an app to send it to.
145pub(crate) fn request_error(err: &anyhow::Error) {
146    if let Some(state) = crate::context::app() {
147        let report = ErrorReport::new(
148            &state,
149            ReportKind::Request,
150            err.to_string(),
151            format!("{err:?}"),
152            None,
153        );
154        send(&state, report);
155    }
156}