Skip to main content

webserver_base/observability/
mod.rs

1//! Sentry and tracing, initialised in the one order that works.
2//!
3//! `sentry::init` binds its hub to the calling thread; threads spawned
4//! afterwards inherit it. `#[tokio::main]` spawns its workers *before* running
5//! the body of `main`, so initialising there never reaches them and every error
6//! raised on a worker is dropped. Hence: initialise on the main thread, then
7//! build the runtime — which is what [`bootstrap`](crate::bootstrap) does.
8//!
9//! [`ObservabilityGuard`] flushes on drop, so it must outlive the server.
10
11use std::fmt::{self, Debug, Formatter};
12
13use tracing::{info, instrument, warn};
14use tracing_subscriber::layer::SubscriberExt;
15use tracing_subscriber::util::SubscriberInitExt;
16use tracing_subscriber::{EnvFilter, Layer};
17
18use crate::env::{self, EnvError};
19use crate::environment::Environment;
20
21/// The environment variable holding the Sentry DSN.
22pub const ENV_SENTRY_DSN: &str = "WSB_SENTRY_SERVER_DSN";
23
24/// The log filter fallback when `RUST_LOG` is unset.
25pub const DEFAULT_LOG_FILTER: &str = "info";
26
27/// Why observability could not be configured.
28#[derive(Debug, thiserror::Error)]
29pub enum ObservabilityError {
30    /// Production without a DSN. Deliberately fatal: a production server with
31    /// monitoring off looks healthy while reporting nothing.
32    #[error(
33        "environment variable `{ENV_SENTRY_DSN}` is required in production; \
34         error monitoring cannot be disabled there"
35    )]
36    MissingDsnInProduction,
37
38    /// An environment variable could not be read.
39    #[error(transparent)]
40    Env(#[from] EnvError),
41}
42
43/// How this process reports errors and logs.
44#[derive(Clone)]
45pub struct Observability {
46    dsn: Option<String>,
47    environment: Environment,
48    log_filter: Option<String>,
49    release: String,
50}
51
52/// The release string used when the application does not name itself.
53///
54/// Sentry's own `release_name!` resolves `CARGO_PKG_NAME` where it is *written*
55/// — inside this crate — so every project that used it would report the same
56/// release and Sentry could not tell one site's deploys from another's. The
57/// application must supply its own; this is only the fallback.
58pub const UNKNOWN_RELEASE: &str = "unknown";
59
60impl Observability {
61    /// Error monitoring pointed at `dsn`.
62    #[must_use]
63    pub fn new(environment: Environment, dsn: impl Into<String>) -> Self {
64        Self {
65            dsn: Some(dsn.into()),
66            environment,
67            log_filter: None,
68            release: String::from(UNKNOWN_RELEASE),
69        }
70    }
71
72    /// Tracing only, with no error monitoring. For local runs and tests.
73    #[must_use]
74    pub fn none(environment: Environment) -> Self {
75        Self {
76            dsn: None,
77            environment,
78            log_filter: None,
79            release: String::from(UNKNOWN_RELEASE),
80        }
81    }
82
83    /// Reads [`ENV_SENTRY_DSN`], which every environment must set.
84    ///
85    /// Required locally too, deliberately: a DSN exercised only in production
86    /// is a DSN nobody has proved works. Point local runs at a development
87    /// Sentry project.
88    ///
89    /// # Errors
90    ///
91    /// [`ObservabilityError::Env`] if it is unset or blank.
92    pub fn from_env(environment: Environment) -> Result<Self, ObservabilityError> {
93        // Required in every environment, not just production. A DSN that is
94        // only exercised in production is a DSN nobody has proved works.
95        let dsn: String = env::required(ENV_SENTRY_DSN)?;
96
97        Ok(Self {
98            dsn: Some(dsn),
99            environment,
100            log_filter: None,
101            release: String::from(UNKNOWN_RELEASE),
102        })
103    }
104
105    /// Names the running build, as `my-project@1.2.3`.
106    ///
107    /// This is what Sentry attributes issues to, so it must identify the
108    /// *application*, not this library. Deriving it here is impossible: the
109    /// crate metadata available inside this crate is this crate's own.
110    #[must_use]
111    pub fn with_release(mut self, release: impl Into<String>) -> Self {
112        self.release = release.into();
113        self
114    }
115
116    /// Overrides the `RUST_LOG` fallback. `RUST_LOG` itself stays unprefixed —
117    /// it is an ecosystem convention.
118    #[must_use]
119    pub fn with_log_filter(mut self, log_filter: impl Into<String>) -> Self {
120        self.log_filter = Some(log_filter.into());
121        self
122    }
123
124    /// Whether error monitoring is configured.
125    #[must_use]
126    pub const fn has_error_monitoring(&self) -> bool {
127        self.dsn.is_some()
128    }
129
130    /// Which deployment this is.
131    #[must_use]
132    pub const fn environment(&self) -> Environment {
133        self.environment
134    }
135
136    /// Installs the tracing subscriber and, when a DSN is set, Sentry.
137    ///
138    /// Main thread, before any runtime. Hold the guard for the process.
139    #[must_use]
140    #[instrument(skip_all)]
141    pub fn init(self) -> ObservabilityGuard {
142        let sentry_guard: Option<sentry::ClientInitGuard> = self.dsn.as_ref().map(|dsn| {
143            // `ClientOptions` is `#[non_exhaustive]` as of sentry 0.49, so it
144            // has to be built by mutation rather than a struct expression.
145            let mut options: sentry::ClientOptions = sentry::ClientOptions::default();
146            // Not `sentry::release_name!()`: that macro reads the crate
147            // metadata of wherever it is expanded, which here is this library —
148            // so every project would report an identical release and Sentry
149            // could not attribute an issue to the deploy that caused it.
150            options.release = Some(self.release.clone().into());
151            options.environment = Some(self.environment.as_str().into());
152            options.attach_stacktrace = true;
153
154            sentry::init((dsn.clone(), options))
155        });
156
157        let fallback: &str = self.log_filter.as_deref().unwrap_or(DEFAULT_LOG_FILTER);
158        let directives: String =
159            std::env::var(EnvFilter::DEFAULT_ENV).unwrap_or_else(|_| fallback.to_string());
160        let filter: EnvFilter =
161            EnvFilter::try_new(&directives).unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER));
162
163        // A human reads the local log; a machine reads the production one.
164        // `with_ansi(false)` in production because a log driver stores escape
165        // codes verbatim and nothing downstream strips them.
166        let format = if self.environment.is_production() {
167            tracing_subscriber::fmt::layer()
168                .json()
169                .flatten_event(true)
170                .with_current_span(true)
171                .with_span_list(false)
172                .with_ansi(false)
173                .with_filter(filter)
174                .boxed()
175        } else {
176            tracing_subscriber::fmt::layer().with_filter(filter).boxed()
177        };
178
179        tracing_subscriber::registry()
180            .with(format)
181            // Harmless without Sentry: the layer forwards to a disabled hub.
182            .with(sentry::integrations::tracing::layer())
183            .init();
184
185        // The one thing no other log line can tell you: which build is running.
186        // Everything else here is either inferable or already stated elsewhere,
187        // so this stays to three fields.
188        info!(
189            release = %self.release,
190            environment = %self.environment,
191            log_filter = %directives,
192            "starting"
193        );
194
195        if sentry_guard.is_none() {
196            warn!("error monitoring is not configured; nothing will reach Sentry");
197        }
198
199        ObservabilityGuard {
200            sentry: sentry_guard,
201        }
202    }
203}
204
205impl Debug for Observability {
206    /// Never prints the DSN — it is a credential, and this type reaches logs.
207    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
208        f.debug_struct("Observability")
209            .field("environment", &self.environment)
210            .field("release", &self.release)
211            // The DSN itself is deliberately absent: it is a credential, and
212            // this type reaches logs.
213            .field("error_monitoring", &self.dsn.is_some())
214            .field("log_filter", &self.log_filter)
215            .finish()
216    }
217}
218
219/// Keeps error monitoring alive, and flushes it on drop. Bind it to `_guard`,
220/// never `_`, which would drop it immediately.
221pub struct ObservabilityGuard {
222    sentry: Option<sentry::ClientInitGuard>,
223}
224
225impl Debug for ObservabilityGuard {
226    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
227        f.debug_struct("ObservabilityGuard")
228            .field("error_monitoring", &self.sentry.is_some())
229            .finish()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::Observability;
236    use crate::environment::Environment;
237
238    #[test]
239    fn debug_output_never_contains_the_dsn() {
240        let observability: Observability =
241            Observability::new(Environment::Production, "https://sup3rs3cret@example.com/1");
242
243        let actual: String = format!("{observability:?}");
244        assert!(!actual.contains("sup3rs3cret"));
245        assert!(actual.contains("error_monitoring: true"));
246    }
247
248    #[test]
249    fn a_custom_log_filter_is_retained() {
250        let observability: Observability =
251            Observability::none(Environment::Local).with_log_filter("debug");
252
253        let actual: String = format!("{observability:?}");
254        assert!(actual.contains("debug"));
255    }
256}