ocre/errors.rs
1//! Error reporting (Rails' `Rails.error`): report errors with context to the logs and to services such as Sentry.
2//!
3//! Every [`Ctx`](crate::Ctx) has a [`Reporter`], [`Ctx::errors`](crate::Ctx::errors).
4//! A report is logged at once (as an `error`, `warn` or `info` line with its
5//! context) and handed to each [`Subscriber`] the app registered with
6//! [`subscribe`]. Ocre reports on its own, with `handled: false`:
7//!
8//! | Source | When |
9//! |---|---|
10//! | `ocre.request` | a handler returned [`Error::Internal`](crate::Error::Internal) (a 500) |
11//! | `ocre.job` | a background job failed (it is retried or discarded) |
12//! | `ocre.cron` | a scheduled task failed |
13//! | `ocre.mailbox` | the inbound email handler failed |
14//!
15//! ```no_run
16//! use axum::extract::State;
17//! use ocre::{Ctx, Result, errors::{Options, Severity}};
18//!
19//! async fn sync_orders(State(ctx): State<Ctx>) -> Result<&'static str> {
20//! ctx.errors().set_context("tenant", "acme"); // added to every report of this request
21//!
22//! // Rails.error.handle: report and go on with a fallback.
23//! let rates: Vec<f64> = ctx.errors().handle(fetch_rates().await).unwrap_or_default();
24//!
25//! // Rails.error.record: report, then fail the request.
26//! ctx.errors().record(charge().await)?;
27//!
28//! // Rails.error.report, with options.
29//! if rates.is_empty() {
30//! let options = Options::new().severity(Severity::Info).context("provider", "ecb").source("rates");
31//! ctx.errors().report(&"no exchange rates today", options);
32//! }
33//! Ok("OK")
34//! }
35//!
36//! async fn fetch_rates() -> Result<Vec<f64>> {
37//! Ok(vec![1.08])
38//! }
39//! async fn charge() -> Result<()> {
40//! Ok(())
41//! }
42//! # let _ = sync_orders;
43//! ```
44//!
45//! # Subscribers
46//!
47//! Register them once per Worker instance, in the `start` event of
48//! `src/lib.rs` (Ocre's initializer):
49//!
50//! ```no_run
51//! #[worker::event(start)]
52//! fn start() {
53//! ocre::errors::subscribe(ocre::errors::Sentry);
54//! }
55//! ```
56//!
57//! [`Sentry`] sends each report to the project of the `SENTRY_DSN` secret
58//! (sentry.io or any Sentry-compatible service, such as GlitchTip or
59//! Bugsink). Other services implement [`Subscriber`]: it turns a [`Report`]
60//! into the HTTP request to send ([`Delivery`]), and Ocre sends it with
61//! `fetch` once the response is ready.
62//!
63//! # Free plan
64//!
65//! Reports cost nothing until a subscriber sends one: then each delivery is
66//! one subrequest (50 per request on the free plan), made after the
67//! handler, before the response goes out, so an error response waits for
68//! it. Only reports that happen are sent; a request without errors makes no
69//! subrequest.
70
71use std::{
72 fmt::Display,
73 sync::{Arc, Mutex, PoisonError},
74};
75
76use serde::Serialize;
77use serde_json::{Map, Value, json};
78
79use crate::{config::Environment, log::Logger};
80
81/// How bad a report is (Rails' `severity:`).
82///
83/// # Examples
84///
85/// ```
86/// assert_eq!(ocre::errors::Severity::Warning.as_str(), "warning");
87/// ```
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum Severity {
90 /// A failure: the default of [`Reporter::record`] and of Ocre's own reports.
91 Error,
92 /// Handled, worth looking at: the default of [`Reporter::report`] and [`Reporter::handle`].
93 Warning,
94 /// For information.
95 Info,
96}
97
98impl Severity {
99 /// `error`, `warning` or `info`, as Sentry names levels.
100 ///
101 /// # Examples
102 ///
103 /// ```
104 /// assert_eq!(ocre::errors::Severity::Error.as_str(), "error");
105 /// ```
106 pub fn as_str(self) -> &'static str {
107 match self {
108 Self::Error => "error",
109 Self::Warning => "warning",
110 Self::Info => "info",
111 }
112 }
113
114 fn level(self) -> crate::log::Level {
115 match self {
116 Self::Error => crate::log::Level::Error,
117 Self::Warning => crate::log::Level::Warn,
118 Self::Info => crate::log::Level::Info,
119 }
120 }
121}
122
123/// Options of one report (Rails' `handled:`, `severity:`, `context:`, `source:`).
124///
125/// Defaults: handled, [`Severity::Warning`], no context, source `application`.
126///
127/// # Examples
128///
129/// ```
130/// use ocre::errors::{Options, Severity};
131///
132/// let options = Options::new().handled(false).severity(Severity::Error).context("order_id", 42).source("billing");
133/// # let _ = options;
134/// ```
135#[derive(Debug, Clone)]
136pub struct Options {
137 handled: bool,
138 severity: Severity,
139 context: Map<String, Value>,
140 source: String,
141 except: Vec<&'static str>,
142}
143
144impl Default for Options {
145 fn default() -> Self {
146 Self {
147 handled: true,
148 severity: Severity::Warning,
149 context: Map::new(),
150 source: "application".to_owned(),
151 except: Vec::new(),
152 }
153 }
154}
155
156impl Options {
157 /// The defaults: handled, warning, no context, source `application`.
158 ///
159 /// # Examples
160 ///
161 /// ```
162 /// let _ = ocre::errors::Options::new();
163 /// ```
164 pub fn new() -> Self {
165 Self::default()
166 }
167
168 /// Whether the app recovered from the error (`false` for errors that failed the request or job).
169 ///
170 /// # Examples
171 ///
172 /// ```
173 /// let _ = ocre::errors::Options::new().handled(false);
174 /// ```
175 pub fn handled(mut self, handled: bool) -> Self {
176 self.handled = handled;
177 self
178 }
179
180 /// The report's [`Severity`].
181 ///
182 /// # Examples
183 ///
184 /// ```
185 /// let _ = ocre::errors::Options::new().severity(ocre::errors::Severity::Info);
186 /// ```
187 pub fn severity(mut self, severity: Severity) -> Self {
188 self.severity = severity;
189 self
190 }
191
192 /// Adds a context entry, merged over the request's [`Reporter::set_context`] entries.
193 ///
194 /// A value that does not serialize is `null`.
195 ///
196 /// # Examples
197 ///
198 /// ```
199 /// let _ = ocre::errors::Options::new().context("user_id", 7);
200 /// ```
201 pub fn context(mut self, key: &str, value: impl Serialize) -> Self {
202 self.context.insert(key.to_owned(), serde_json::to_value(value).unwrap_or(Value::Null));
203 self
204 }
205
206 /// Where the error comes from, e.g. `billing` (Rails' `source:`).
207 ///
208 /// # Examples
209 ///
210 /// ```
211 /// let _ = ocre::errors::Options::new().source("webhooks");
212 /// ```
213 pub fn source(mut self, source: &str) -> Self {
214 self.source = source.to_owned();
215 self
216 }
217
218 /// Does not send this report to the subscriber named `name` (Rails' `Rails.error.disable`).
219 ///
220 /// For errors a subscriber should not see, such as the failure of its
221 /// own service. The report is still logged.
222 ///
223 /// # Examples
224 ///
225 /// ```
226 /// let _ = ocre::errors::Options::new().except("sentry");
227 /// ```
228 pub fn except(mut self, subscriber: &'static str) -> Self {
229 self.except.push(subscriber);
230 self
231 }
232}
233
234/// One reported error, as subscribers receive it.
235///
236/// # Examples
237///
238/// ```
239/// use ocre::errors::{Options, Reporter};
240///
241/// let reporter = Reporter::default();
242/// reporter.report(&"quota exceeded", Options::new().context("plan", "free"));
243/// let report = &reporter.take()[0];
244/// assert_eq!((report.message.as_str(), report.class.as_str()), ("quota exceeded", "&str"));
245/// assert_eq!(report.context["plan"], "free");
246/// ```
247#[derive(Debug, Clone, PartialEq)]
248pub struct Report {
249 /// The error's type, e.g. `ocre::error::Error` (Sentry's exception type).
250 pub class: String,
251 /// The error's text (`Display`).
252 pub message: String,
253 /// Whether the app recovered from it.
254 pub handled: bool,
255 /// How bad it is.
256 pub severity: Severity,
257 /// The request's context, then the report's own entries.
258 pub context: Map<String, Value>,
259 /// Where it comes from: `application`, `ocre.request`, `ocre.job`...
260 pub source: String,
261 /// When it was reported, in Unix seconds.
262 pub timestamp: i64,
263 /// Development or production, from the build profile.
264 pub environment: Environment,
265 except: Vec<&'static str>,
266}
267
268/// An HTTP `POST` a [`Subscriber`] asks Ocre to send for a report.
269///
270/// # Examples
271///
272/// ```
273/// let delivery = ocre::errors::Delivery {
274/// url: "https://errors.example/api/reports".into(),
275/// headers: vec![("content-type".into(), "application/json".into())],
276/// body: "{}".into(),
277/// };
278/// # let _ = delivery;
279/// ```
280#[derive(Debug, Clone, PartialEq)]
281pub struct Delivery {
282 /// Where to `POST`.
283 pub url: String,
284 /// Request headers, e.g. an API key.
285 pub headers: Vec<(String, String)>,
286 /// The request body.
287 pub body: String,
288}
289
290/// An error-reporting service (Rails' error subscribers): turns a [`Report`] into the request to send.
291///
292/// `vars` looks up a Worker variable or secret by name (the service's key).
293/// Return `None` to send nothing, e.g. when the key is not set. Ocre sends
294/// the [`Delivery`] with `fetch` and logs a failed send; it never retries.
295///
296/// # Examples
297///
298/// ```
299/// use ocre::errors::{Delivery, Report, Subscriber};
300///
301/// /// Posts each error to a webhook (a chat channel, an incident tool...).
302/// struct Webhook;
303///
304/// impl Subscriber for Webhook {
305/// fn name(&self) -> &'static str {
306/// "webhook"
307/// }
308///
309/// fn deliver(&self, report: &Report, vars: &dyn Fn(&str) -> Option<String>) -> Option<Delivery> {
310/// let url = vars("ERRORS_WEBHOOK_URL")?;
311/// let body = serde_json::json!({ "text": format!("{}: {}", report.source, report.message) }).to_string();
312/// Some(Delivery { url, headers: vec![("content-type".into(), "application/json".into())], body })
313/// }
314/// }
315///
316/// ocre::errors::subscribe(Webhook);
317/// ```
318pub trait Subscriber: Send + Sync {
319 /// A short name, for [`Options::except`] and failure logs: `sentry`.
320 fn name(&self) -> &'static str;
321
322 /// The request to send for `report`, if any.
323 fn deliver(&self, report: &Report, vars: &dyn Fn(&str) -> Option<String>) -> Option<Delivery>;
324}
325
326static SUBSCRIBERS: Mutex<Vec<Arc<dyn Subscriber>>> = Mutex::new(Vec::new());
327
328/// Registers a subscriber for every report of this Worker instance (Rails' `Rails.error.subscribe`).
329///
330/// Call it from the `start` event (see the [module documentation](self)),
331/// which runs once per instance; a second subscriber with the same
332/// [`name`](Subscriber::name) replaces the first.
333///
334/// # Examples
335///
336/// ```
337/// ocre::errors::subscribe(ocre::errors::Sentry);
338/// ```
339pub fn subscribe(subscriber: impl Subscriber + 'static) {
340 let mut subscribers = SUBSCRIBERS.lock().unwrap_or_else(PoisonError::into_inner);
341 subscribers.retain(|existing| existing.name() != subscriber.name());
342 subscribers.push(Arc::new(subscriber));
343}
344
345/// The deliveries of `reports` for every registered subscriber, with the
346/// subscriber's name: what the runtime sends after a request or job.
347pub(crate) fn deliveries(reports: &[Report], vars: &dyn Fn(&str) -> Option<String>) -> Vec<(&'static str, Delivery)> {
348 let subscribers = SUBSCRIBERS.lock().unwrap_or_else(PoisonError::into_inner).clone();
349 let mut out = Vec::new();
350 for report in reports {
351 for subscriber in subscribers.iter().filter(|s| !report.except.contains(&s.name())) {
352 if let Some(delivery) = subscriber.deliver(report, vars) {
353 out.push((subscriber.name(), delivery));
354 }
355 }
356 }
357 out
358}
359
360#[derive(Debug, Default)]
361struct State {
362 context: Map<String, Value>,
363 pending: Vec<Report>,
364}
365
366/// The error reporter of one request, job batch or cron run: [`Ctx::errors`](crate::Ctx::errors).
367///
368/// Cheap to clone; clones share their context and pending reports.
369/// `Reporter::default()` builds one for unit tests, whose reports are read
370/// back with [`take`](Self::take).
371///
372/// # Examples
373///
374/// ```
375/// use ocre::errors::{Reporter, Severity};
376///
377/// let reporter = Reporter::default();
378/// let value: Option<i32> = reporter.handle("x".parse::<i32>());
379/// assert_eq!(value, None);
380/// let reports = reporter.take();
381/// assert_eq!((reports[0].handled, reports[0].severity), (true, Severity::Warning));
382/// ```
383#[derive(Debug, Clone, Default)]
384pub struct Reporter {
385 state: Arc<Mutex<State>>,
386 log: Logger,
387}
388
389impl Reporter {
390 /// A reporter whose log lines carry the fields of `log` (the request id...).
391 pub(crate) fn new(log: Logger) -> Self {
392 Self { state: Arc::default(), log }
393 }
394
395 fn lock(&self) -> std::sync::MutexGuard<'_, State> {
396 self.state.lock().unwrap_or_else(PoisonError::into_inner)
397 }
398
399 /// Adds context to every later report of this request or job (Rails' `Rails.error.set_context`).
400 ///
401 /// # Examples
402 ///
403 /// ```
404 /// let reporter = ocre::errors::Reporter::default();
405 /// reporter.set_context("user_id", 7);
406 /// reporter.report(&"oops", ocre::errors::Options::new());
407 /// assert_eq!(reporter.take()[0].context["user_id"], 7);
408 /// ```
409 pub fn set_context(&self, key: &str, value: impl Serialize) {
410 self.lock().context.insert(key.to_owned(), serde_json::to_value(value).unwrap_or(Value::Null));
411 }
412
413 /// Reports an error (Rails' `Rails.error.report`): logs it and queues it for the subscribers.
414 ///
415 /// # Examples
416 ///
417 /// ```
418 /// let reporter = ocre::errors::Reporter::default();
419 /// let err = ocre::Error::internal("webhook signature mismatch");
420 /// reporter.report(&err, ocre::errors::Options::new().source("webhooks"));
421 /// assert_eq!(reporter.take()[0].message, "internal error: webhook signature mismatch");
422 /// ```
423 pub fn report<E: Display + ?Sized>(&self, error: &E, options: Options) {
424 self.push(std::any::type_name::<E>(), error.to_string(), options, true);
425 }
426
427 /// Reports the error of `result`, if any, as handled, and returns its value (Rails' `Rails.error.handle`).
428 ///
429 /// `.unwrap_or(fallback)` gives Rails' `fallback:`. Only errors of the
430 /// result's type are caught: other errors propagate with `?` before
431 /// reaching it (Rails' class filter).
432 ///
433 /// # Examples
434 ///
435 /// ```
436 /// let reporter = ocre::errors::Reporter::default();
437 /// assert_eq!(reporter.handle("4".parse::<u8>()), Some(4));
438 /// assert_eq!(reporter.handle("x".parse::<u8>()).unwrap_or(1), 1);
439 /// assert_eq!(reporter.take().len(), 1);
440 /// ```
441 pub fn handle<T, E: Display>(&self, result: Result<T, E>) -> Option<T> {
442 result.map_err(|err| self.report(&err, Options::new())).ok()
443 }
444
445 /// Reports the error of `result`, if any, as unhandled, and returns `result` unchanged (Rails' `Rails.error.record`).
446 ///
447 /// # Examples
448 ///
449 /// ```
450 /// use ocre::errors::{Reporter, Severity};
451 ///
452 /// let reporter = Reporter::default();
453 /// assert!(reporter.record("x".parse::<u8>()).is_err());
454 /// let report = &reporter.take()[0];
455 /// assert_eq!((report.handled, report.severity), (false, Severity::Error));
456 /// ```
457 pub fn record<T, E: Display>(&self, result: Result<T, E>) -> Result<T, E> {
458 result.inspect_err(|err| self.report(err, Options::new().handled(false).severity(Severity::Error)))
459 }
460
461 /// Reports something that should never happen (Rails' `Rails.error.unexpected`).
462 ///
463 /// Panics in debug builds (`ocre dev`, tests), so the bug is seen at
464 /// once; in release builds it is reported as an error and the code goes on.
465 ///
466 /// # Panics
467 ///
468 /// In debug builds, always.
469 ///
470 /// # Examples
471 ///
472 /// ```should_panic
473 /// ocre::errors::Reporter::default().unexpected("a paid order has no invoice");
474 /// ```
475 #[track_caller]
476 pub fn unexpected(&self, message: impl Display) {
477 self.unexpected_in(&message, cfg!(debug_assertions));
478 }
479
480 #[track_caller]
481 fn unexpected_in(&self, message: &dyn Display, raise: bool) {
482 assert!(!raise, "unexpected: {message}");
483 let location = std::panic::Location::caller().to_string();
484 let options = Options::new().severity(Severity::Error).context("location", location).source("unexpected");
485 self.push("unexpected", message.to_string(), options, true);
486 }
487
488 /// Ocre's own report of an unhandled error from `source` (`ocre.request`...);
489 /// `log: false` when the caller already logged its own line (jobs, cron).
490 pub(crate) fn report_unhandled(&self, message: &str, source: &str, context: Map<String, Value>, log: bool) {
491 let mut options = Options::new().handled(false).severity(Severity::Error).source(source);
492 options.context = context;
493 self.push("ocre::Error", message.to_owned(), options, log);
494 }
495
496 /// Takes the reports made so far, for the subscribers (or a test).
497 ///
498 /// # Examples
499 ///
500 /// ```
501 /// let reporter = ocre::errors::Reporter::default();
502 /// assert!(reporter.take().is_empty());
503 /// ```
504 pub fn take(&self) -> Vec<Report> {
505 std::mem::take(&mut self.lock().pending)
506 }
507
508 fn push(&self, class: &str, message: String, options: Options, logged: bool) {
509 let mut state = self.lock();
510 let mut context = state.context.clone();
511 context.extend(options.context);
512 let report = Report {
513 class: class.to_owned(),
514 message,
515 handled: options.handled,
516 severity: options.severity,
517 context,
518 source: options.source,
519 timestamp: crate::now(),
520 environment: Environment::current(),
521 except: options.except,
522 };
523 if logged {
524 let prefix = if report.source.starts_with("ocre.") { "[ocre] " } else { "" };
525 let mut log = self.log.with("error_class", &report.class).with("handled", report.handled);
526 log = log.with("source", &report.source);
527 for (key, value) in &report.context {
528 log = log.with(key, value);
529 }
530 log.log(report.severity.level(), &format_args!("{prefix}{}", report.message));
531 }
532 state.pending.push(report);
533 }
534}
535
536/// Worker secret holding the Sentry DSN: `https://<key>@<host>/<project id>`.
537///
538/// # Examples
539///
540/// ```
541/// assert_eq!(ocre::errors::SENTRY_DSN, "SENTRY_DSN");
542/// ```
543pub const SENTRY_DSN: &str = "SENTRY_DSN";
544
545/// Sends reports to Sentry or a Sentry-compatible service, as envelopes (the Sentry ingestion protocol).
546///
547/// Reads the DSN from the [`SENTRY_DSN`] secret (`ocre secrets push
548/// SENTRY_DSN --file .prod.vars`); without it, sends nothing. The optional
549/// `SENTRY_RELEASE` variable sets the release. Each report becomes one
550/// event: the error as the exception (type [`Report::class`], value
551/// [`Report::message`], mechanism [`Report::source`] and `handled`), the
552/// severity as level, the environment, `request_id` as a tag and the
553/// context (filtered like logs) as extra data. One subrequest per report.
554///
555/// # Examples
556///
557/// ```
558/// use ocre::errors::{Options, Reporter, Sentry, Subscriber};
559///
560/// let reporter = Reporter::default();
561/// reporter.report(&"boom", Options::new());
562/// let report = &reporter.take()[0];
563/// let dsn = |name: &str| (name == "SENTRY_DSN").then(|| "https://abc@o1.ingest.sentry.io/42".to_owned());
564/// let delivery = Sentry.deliver(report, &dsn).unwrap();
565/// assert_eq!(delivery.url, "https://o1.ingest.sentry.io/api/42/envelope/");
566/// assert!(Sentry.deliver(report, &|_| None).is_none());
567/// ```
568#[derive(Debug, Clone, Copy)]
569pub struct Sentry;
570
571impl Subscriber for Sentry {
572 fn name(&self) -> &'static str {
573 "sentry"
574 }
575
576 fn deliver(&self, report: &Report, vars: &dyn Fn(&str) -> Option<String>) -> Option<Delivery> {
577 let dsn = vars(SENTRY_DSN)?;
578 let Some((scheme, key, host, project)) = parse_dsn(&dsn) else {
579 crate::error::log_internal(&format!(
580 "{SENTRY_DSN} is not a Sentry DSN (https://<key>@<host>/<project id>)"
581 ));
582 return None;
583 };
584 let event_id: String = crate::token::random_bytes::<16>().iter().map(|b| format!("{b:02x}")).collect();
585 let mut tags = json!({ "source": report.source });
586 if let Some(id) = report.context.get("request_id") {
587 tags["request_id"] = id.clone();
588 }
589 let mut event = json!({
590 "event_id": event_id,
591 "timestamp": report.timestamp,
592 "platform": "other",
593 "level": report.severity.as_str(),
594 "logger": report.source,
595 "environment": report.environment.as_str(),
596 "exception": { "values": [{
597 "type": report.class,
598 "value": report.message,
599 "mechanism": { "type": report.source, "handled": report.handled },
600 }] },
601 "tags": tags,
602 "extra": crate::security::filter_json(&Value::Object(report.context.clone())),
603 "sdk": { "name": "ocre", "version": env!("CARGO_PKG_VERSION") },
604 });
605 if let Some(release) = vars("SENTRY_RELEASE") {
606 event["release"] = release.into();
607 }
608 let header = json!({ "event_id": event_id, "dsn": dsn });
609 let body = format!("{header}\n{}\n{event}\n", json!({ "type": "event" }));
610 let auth =
611 format!("Sentry sentry_version=7, sentry_key={key}, sentry_client=ocre/{}", env!("CARGO_PKG_VERSION"));
612 Some(Delivery {
613 url: format!("{scheme}://{host}/api/{project}/envelope/"),
614 headers: vec![
615 ("content-type".to_owned(), "application/x-sentry-envelope".to_owned()),
616 ("x-sentry-auth".to_owned(), auth),
617 ],
618 body,
619 })
620 }
621}
622
623/// `https://key@host/path/42` into (`https`, `key`, `host/path`, `42`).
624fn parse_dsn(dsn: &str) -> Option<(&str, &str, &str, &str)> {
625 let (scheme, rest) = dsn.trim().split_once("://")?;
626 let (key, location) = rest.split_once('@')?;
627 let key = key.split(':').next().unwrap_or(key);
628 let (host, project) = location.trim_end_matches('/').rsplit_once('/')?;
629 let valid =
630 !key.is_empty() && !host.is_empty() && !project.is_empty() && project.bytes().all(|b| b.is_ascii_digit());
631 valid.then_some((scheme, key, host, project))
632}
633
634/// What the development error page shows of the request.
635#[derive(Debug, Clone, Default)]
636pub(crate) struct RequestDetails {
637 pub request_id: String,
638 pub method: String,
639 pub path: String,
640 /// Filtered with `security::filter_parameters`.
641 pub query: String,
642 /// Filtered: `cookie`, `authorization` and sensitive names hidden.
643 pub headers: Vec<(String, String)>,
644}
645
646impl RequestDetails {
647 /// The request's id, method and path; the query and headers only for
648 /// the development error page (`dev`).
649 pub fn new(
650 request_id: &str,
651 method: &str,
652 uri: &axum::http::Uri,
653 headers: &axum::http::HeaderMap,
654 dev: bool,
655 ) -> Self {
656 let mut details = Self {
657 request_id: request_id.to_owned(),
658 method: method.to_owned(),
659 path: uri.path().to_owned(),
660 ..Self::default()
661 };
662 if dev {
663 details.query = crate::security::filter_parameters(uri.query().unwrap_or_default());
664 details.headers = headers
665 .iter()
666 .map(|(name, value)| {
667 (name.to_string(), shown_header(name.as_str(), value.to_str().unwrap_or("(binary)")))
668 })
669 .collect();
670 }
671 details
672 }
673
674 /// The logger of the request: every line carries its id, method and path.
675 pub fn logger(&self) -> Logger {
676 Logger::new().with("request_id", &self.request_id).with("method", &self.method).with("path", &self.path)
677 }
678}
679
680/// What `serve` does with the router's response: reports an
681/// [`Error::Internal`](crate::Error::Internal) (logged with the request's
682/// fields), sets `X-Request-Id`, logs the `debug` request line, and in
683/// debug builds (`dev`) adds `Server-Timing` and shows the development
684/// error page (HTML) or the internal message as `error.detail` (JSON).
685pub(crate) async fn finish(
686 mut response: axum::response::Response,
687 request: &RequestDetails,
688 reporter: &Reporter,
689 timings: &crate::instrument::Timings,
690 total_ms: f64,
691 dev: bool,
692) -> axum::response::Response {
693 use axum::http::HeaderValue;
694
695 if let Some(crate::error::InternalError(message)) = response.extensions_mut().remove() {
696 let context = Map::from_iter([
697 ("request_id".to_owned(), Value::from(request.request_id.as_str())),
698 ("method".to_owned(), Value::from(request.method.as_str())),
699 ("path".to_owned(), Value::from(request.path.as_str())),
700 ]);
701 reporter.report_unhandled(&message, "ocre.request", context, true);
702 if dev {
703 response = dev_response(response, &message, request, timings).await;
704 }
705 }
706 if let Ok(id) = HeaderValue::from_str(&request.request_id) {
707 response.headers_mut().entry("x-request-id").or_insert(id);
708 }
709 if dev && let Ok(value) = HeaderValue::from_str(&timings.server_timing(total_ms)) {
710 response.headers_mut().insert("server-timing", value);
711 }
712 let status = response.status().as_u16();
713 let mut line = timings.summary(&request.method, &request.path, status, total_ms);
714 // Rails' verbose redirect logs: where a redirect sends the browser.
715 if let Some(location) = response.headers().get(axum::http::header::LOCATION).and_then(|value| value.to_str().ok()) {
716 line.push_str(&format!(" -> {location}"));
717 }
718 reporter.log.debug(line);
719 response
720}
721
722/// The development form of a 500: the error page with the internal
723/// message, or the JSON error with `detail`.
724async fn dev_response(
725 response: axum::response::Response,
726 message: &str,
727 request: &RequestDetails,
728 timings: &crate::instrument::Timings,
729) -> axum::response::Response {
730 use axum::http::{HeaderValue, header};
731
732 let content_type = response.headers().get(header::CONTENT_TYPE).and_then(|value| value.to_str().ok());
733 let (html, json) =
734 content_type.map_or((false, false), |value| (value.starts_with("text/html"), value.contains("json")));
735 let (mut parts, body) = response.into_parts();
736 let body = if html {
737 dev_page(parts.status.as_u16(), message, request, &timings.statements())
738 } else if json {
739 let bytes = axum::body::to_bytes(body, 1 << 20).await.unwrap_or_default();
740 let mut value: Value = serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({ "error": {} }));
741 value["error"]["detail"] = Value::from(message);
742 value.to_string()
743 } else {
744 return axum::response::Response::from_parts(parts, body);
745 };
746 parts.headers.remove(header::CONTENT_LENGTH);
747 if html {
748 parts.headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
749 }
750 axum::response::Response::from_parts(parts, axum::body::Body::from(body))
751}
752
753/// The development error page (debug builds only): the internal message,
754/// the request and the D1 statements it ran (Rails' development exception page).
755pub(crate) fn dev_page(
756 status: u16,
757 message: &str,
758 request: &RequestDetails,
759 statements: &[crate::instrument::Timing],
760) -> String {
761 let e = escape;
762 let mut page = format!(
763 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{status} {title}</title><style>body{{font-family:system-ui,sans-serif;margin:2rem;max-width:70rem}}h1{{color:#b3261e}}pre{{background:#f6f6f6;padding:1rem;overflow:auto;white-space:pre-wrap}}table{{border-collapse:collapse}}td,th{{border:1px solid #ddd;padding:.25rem .5rem;text-align:left;vertical-align:top}}</style></head><body>",
764 title = e(message.lines().next().unwrap_or(message))
765 );
766 page.push_str(&format!("<h1>{status} Internal server error</h1><pre>{}</pre>", e(message)));
767 page.push_str("<p>This page shows because the Worker is a debug build (<code>ocre dev</code>). After <code>ocre deploy</code>, users see the error page and the details go to the logs and error reporters.</p>");
768 page.push_str(&format!(
769 "<h2>Request</h2><table><tr><th>Request id</th><td>{}</td></tr><tr><th>Method</th><td>{}</td></tr><tr><th>Path</th><td>{}</td></tr><tr><th>Query</th><td>{}</td></tr></table>",
770 e(&request.request_id),
771 e(&request.method),
772 e(&request.path),
773 e(&request.query)
774 ));
775 page.push_str("<h2>Headers</h2><table>");
776 for (name, value) in &request.headers {
777 page.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", e(name), e(value)));
778 }
779 page.push_str(&format!("</table><h2>D1 statements ({})</h2><table>", statements.len()));
780 for statement in statements {
781 page.push_str(&format!("<tr><td>{} ms</td><td><code>{}</code></td></tr>", statement.ms, e(&statement.sql)));
782 }
783 page.push_str("</table></body></html>");
784 page
785}
786
787/// Header values shown on the development error page: secrets hidden.
788pub(crate) fn shown_header(name: &str, value: &str) -> String {
789 let hidden = ["cookie", "authorization", "proxy-authorization"].contains(&name)
790 || crate::security::filter_json(&json!({ name: value }))[name] != json!(value);
791 if hidden { "[FILTERED]".to_owned() } else { value.to_owned() }
792}
793
794fn escape(text: &str) -> String {
795 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
796}
797
798#[cfg(test)]
799#[path = "../tests/errors.rs"]
800mod tests;