Skip to main content

docspec_http/telemetry/
sentry.rs

1//! Sentry telemetry backend internals.
2
3use core::str::FromStr as _;
4
5/// Initializes the Sentry SDK with sanitized client options.
6pub(in crate::telemetry) fn init_sentry(
7    data_source_name: &str,
8) -> Option<::sentry::ClientInitGuard> {
9    sentry::types::Dsn::from_str(data_source_name).map_or_else(
10        |_| {
11            eprintln!("warning: invalid Sentry DSN format; Sentry disabled");
12            None
13        },
14        |parsed_data_source_name| Some(sentry::init(client_options(parsed_data_source_name))),
15    )
16}
17
18fn client_options(parsed_data_source_name: sentry::types::Dsn) -> sentry::ClientOptions {
19    let mut options = sentry::ClientOptions::new()
20        .maybe_release(sentry::release_name!())
21        .environment(env_environment())
22        .sample_rate(env_sample_rate())
23        .send_default_pii(false)
24        .attach_stacktrace(true)
25        .before_send(before_send);
26
27    // Zero must stay `Disabled`, not `FixedRate(0.0)`: the latter still honours
28    // an inherited sampling decision from an incoming trace.
29    let traces_sample_rate = env_traces_sample_rate();
30    if traces_sample_rate > 0.0 {
31        options = options.traces_sample_rate(traces_sample_rate);
32    }
33
34    // Assigned directly because the builder's `dsn` takes a `&str` and panics on
35    // parse failure; this DSN is already parsed.
36    options.dsn = Some(parsed_data_source_name);
37    options
38}
39
40fn env_environment() -> std::borrow::Cow<'static, str> {
41    match std::env::var("SENTRY_ENVIRONMENT") {
42        Ok(value) if !value.is_empty() => std::borrow::Cow::Owned(value),
43        _ => std::borrow::Cow::Borrowed("production"),
44    }
45}
46
47fn env_sample_rate() -> f32 {
48    super::env_rate("SENTRY_SAMPLE_RATE", 1.0)
49}
50
51fn env_traces_sample_rate() -> f32 {
52    super::env_rate("SENTRY_TRACES_SAMPLE_RATE", 0.0)
53}
54
55fn before_send(
56    mut event: sentry::protocol::Event<'static>,
57) -> Option<sentry::protocol::Event<'static>> {
58    if let Some(request) = event.request.as_mut() {
59        let _ = request.data.take();
60    }
61    event
62        .extra
63        .retain(|extra_key, _| !extra_key.to_lowercase().contains("body"));
64    Some(event)
65}
66
67/// Returns the configured Sentry tracing layer.
68pub(in crate::telemetry) fn tracing_layer<S>() -> sentry::integrations::tracing::SentryLayer<S>
69where
70    S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>,
71{
72    sentry::integrations::tracing::layer().event_filter(|metadata| {
73        use sentry::integrations::tracing::EventFilter;
74
75        match *metadata.level() {
76            tracing::Level::ERROR => EventFilter::Event,
77            tracing::Level::WARN | tracing::Level::INFO | tracing::Level::DEBUG => {
78                EventFilter::Breadcrumb
79            }
80            tracing::Level::TRACE => EventFilter::Ignore,
81        }
82    })
83}
84
85/// Returns the Sentry tower hub binding layer.
86pub(in crate::telemetry) fn tower_new_layer(
87) -> sentry::integrations::tower::NewSentryLayer<axum::http::Request<axum::body::Body>> {
88    sentry::integrations::tower::NewSentryLayer::new_from_top()
89}
90
91/// Returns the Sentry HTTP request enrichment layer.
92pub(in crate::telemetry) fn tower_http_layer() -> sentry::integrations::tower::SentryHttpLayer {
93    sentry::integrations::tower::SentryHttpLayer::new()
94}
95
96#[cfg(test)]
97mod tests {
98    // Reason: tests that parse a DSN with `?` still assert with `assert!`;
99    // the lint only makes sense for production code.
100    #![allow(clippy::panic_in_result_fn)]
101
102    use std::sync::Mutex;
103
104    static ENV_MUTEX: Mutex<()> = Mutex::new(());
105
106    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
107        match ENV_MUTEX.lock() {
108            Ok(guard) => guard,
109            Err(poisoned) => poisoned.into_inner(),
110        }
111    }
112
113    #[test]
114    fn before_send_strips_request_data() {
115        let event: sentry::protocol::Event<'static> = sentry::protocol::Event {
116            request: Some(sentry::protocol::Request {
117                data: Some(String::from("# secret document body")),
118                ..sentry::protocol::Request::default()
119            }),
120            ..sentry::protocol::Event::default()
121        };
122
123        let stripped = super::before_send(event);
124
125        assert!(matches!(
126            stripped
127                .as_ref()
128                .and_then(|stripped_event| stripped_event.request.as_ref()),
129            Some(request) if request.data.is_none()
130        ));
131    }
132
133    #[test]
134    fn before_send_strips_body_keys_in_extra() {
135        let mut event: sentry::protocol::Event<'static> = sentry::protocol::Event::default();
136        event
137            .extra
138            .insert(String::from("request_body"), serde_json::json!("secret"));
139        event
140            .extra
141            .insert(String::from("response_body"), serde_json::json!("secret"));
142        event
143            .extra
144            .insert(String::from("other"), serde_json::json!("kept"));
145
146        let stripped = super::before_send(event);
147
148        assert!(
149            matches!(stripped, Some(stripped_event) if stripped_event.extra.len() == 1 && stripped_event.extra.contains_key("other"))
150        );
151    }
152
153    #[test]
154    fn env_sample_rate_clamps_high() {
155        let _env_guard = lock_env();
156        std::env::set_var("SENTRY_SAMPLE_RATE", "5.0");
157
158        assert_eq!(super::env_sample_rate().to_bits(), f32::to_bits(1.0));
159
160        std::env::remove_var("SENTRY_SAMPLE_RATE");
161    }
162
163    #[test]
164    fn env_sample_rate_clamps_low() {
165        let _env_guard = lock_env();
166        std::env::set_var("SENTRY_SAMPLE_RATE", "-0.5");
167
168        assert_eq!(super::env_sample_rate().to_bits(), f32::to_bits(0.0));
169
170        std::env::remove_var("SENTRY_SAMPLE_RATE");
171    }
172
173    #[test]
174    fn env_sample_rate_defaults_to_one() {
175        let _env_guard = lock_env();
176        std::env::remove_var("SENTRY_SAMPLE_RATE");
177
178        assert_eq!(super::env_sample_rate().to_bits(), f32::to_bits(1.0));
179    }
180
181    #[test]
182    fn env_traces_sample_rate_defaults_to_zero() {
183        let _env_guard = lock_env();
184        std::env::remove_var("SENTRY_TRACES_SAMPLE_RATE");
185
186        assert_eq!(super::env_traces_sample_rate().to_bits(), f32::to_bits(0.0));
187    }
188
189    #[test]
190    fn env_environment_defaults_to_production() {
191        let _env_guard = lock_env();
192        std::env::remove_var("SENTRY_ENVIRONMENT");
193
194        assert_eq!(super::env_environment(), "production");
195    }
196
197    #[test]
198    fn init_sentry_returns_none_on_invalid_dsn() {
199        assert!(super::init_sentry("not-a-dsn").is_none());
200    }
201
202    #[test]
203    fn env_traces_sample_rate_ignores_non_finite() {
204        let _env_guard = lock_env();
205        std::env::set_var("SENTRY_TRACES_SAMPLE_RATE", "NaN");
206
207        assert_eq!(super::env_traces_sample_rate().to_bits(), f32::to_bits(0.0));
208
209        std::env::remove_var("SENTRY_TRACES_SAMPLE_RATE");
210    }
211
212    #[test]
213    fn client_options_enables_attach_stacktrace() -> Result<(), Box<dyn core::error::Error>> {
214        let _env_guard = lock_env();
215        let dsn: sentry::types::Dsn = "https://public@example.com/1".parse()?;
216        let opts = super::client_options(dsn);
217        if opts.attach_stacktrace {
218            Ok(())
219        } else {
220            Err("attach_stacktrace should be true".into())
221        }
222    }
223
224    #[test]
225    fn client_options_sets_dsn_release_environment_and_sample_rate(
226    ) -> Result<(), Box<dyn core::error::Error>> {
227        let _env_guard = lock_env();
228        std::env::set_var("SENTRY_ENVIRONMENT", "staging");
229        std::env::set_var("SENTRY_SAMPLE_RATE", "0.25");
230        let dsn: sentry::types::Dsn = "https://public@example.com/1".parse()?;
231
232        let opts = super::client_options(dsn.clone());
233
234        std::env::remove_var("SENTRY_ENVIRONMENT");
235        std::env::remove_var("SENTRY_SAMPLE_RATE");
236
237        assert_eq!(opts.dsn, Some(dsn));
238        assert_eq!(opts.release, sentry::release_name!());
239        assert_eq!(
240            opts.environment,
241            Some(std::borrow::Cow::Borrowed("staging"))
242        );
243        assert!(!opts.send_default_pii);
244        assert!(opts.before_send.is_some());
245        assert!(matches!(
246            opts.event_sampling_strategy,
247            sentry::EventSamplingStrategy::FixedRate(rate) if rate.to_bits() == f32::to_bits(0.25)
248        ));
249        Ok(())
250    }
251
252    #[test]
253    fn client_options_leaves_traces_disabled_at_zero() -> Result<(), Box<dyn core::error::Error>> {
254        let _env_guard = lock_env();
255        std::env::remove_var("SENTRY_TRACES_SAMPLE_RATE");
256        let dsn: sentry::types::Dsn = "https://public@example.com/1".parse()?;
257
258        let opts = super::client_options(dsn);
259
260        assert!(matches!(
261            opts.traces_sampling_strategy,
262            sentry::TracesSamplingStrategy::Disabled
263        ));
264        Ok(())
265    }
266
267    #[test]
268    fn client_options_sets_fixed_traces_rate_above_zero() -> Result<(), Box<dyn core::error::Error>>
269    {
270        let _env_guard = lock_env();
271        std::env::set_var("SENTRY_TRACES_SAMPLE_RATE", "0.5");
272        let dsn: sentry::types::Dsn = "https://public@example.com/1".parse()?;
273
274        let opts = super::client_options(dsn);
275
276        std::env::remove_var("SENTRY_TRACES_SAMPLE_RATE");
277
278        assert!(matches!(
279            opts.traces_sampling_strategy,
280            sentry::TracesSamplingStrategy::FixedRate(rate) if rate.to_bits() == f32::to_bits(0.5)
281        ));
282        Ok(())
283    }
284}