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