Skip to main content

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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
796}
797
798#[cfg(test)]
799#[path = "../tests/errors.rs"]
800mod tests;