webserver_base/observability/
mod.rs1use 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
21pub const ENV_SENTRY_DSN: &str = "WSB_SENTRY_SERVER_DSN";
23
24pub const DEFAULT_LOG_FILTER: &str = "info";
26
27#[derive(Debug, thiserror::Error)]
29pub enum ObservabilityError {
30 #[error(
33 "environment variable `{ENV_SENTRY_DSN}` is required in production; \
34 error monitoring cannot be disabled there"
35 )]
36 MissingDsnInProduction,
37
38 #[error(transparent)]
40 Env(#[from] EnvError),
41}
42
43#[derive(Clone)]
45pub struct Observability {
46 dsn: Option<String>,
47 environment: Environment,
48 log_filter: Option<String>,
49 release: String,
50}
51
52pub const UNKNOWN_RELEASE: &str = "unknown";
59
60impl Observability {
61 #[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 #[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 pub fn from_env(environment: Environment) -> Result<Self, ObservabilityError> {
93 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 #[must_use]
111 pub fn with_release(mut self, release: impl Into<String>) -> Self {
112 self.release = release.into();
113 self
114 }
115
116 #[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 #[must_use]
126 pub const fn has_error_monitoring(&self) -> bool {
127 self.dsn.is_some()
128 }
129
130 #[must_use]
132 pub const fn environment(&self) -> Environment {
133 self.environment
134 }
135
136 #[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 let mut options: sentry::ClientOptions = sentry::ClientOptions::default();
146 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 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 .with(sentry::integrations::tracing::layer())
183 .init();
184
185 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 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
208 f.debug_struct("Observability")
209 .field("environment", &self.environment)
210 .field("release", &self.release)
211 .field("error_monitoring", &self.dsn.is_some())
214 .field("log_filter", &self.log_filter)
215 .finish()
216 }
217}
218
219pub 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}