rocket_sentry/
lib.rs

1#![warn(clippy::pedantic)]
2#![warn(clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5//! **rocket-sentry** is a simple add-on for the **Rocket** web framework to simplify
6//! integration with the **Sentry** application monitoring system.
7//!
8//! Or maybe...
9//!
10//! > "The Rocket Sentry is a static rocket-firing gun platform that is based on a
11//! > Personality Construct and used in the Aperture Science Enrichment Center."
12//! >
13//! > -- [Half-Life wiki](https://half-life.fandom.com/wiki/Rocket_Sentry)
14//!
15//! Example usage
16//! =============
17//!
18//! ```no_run
19//! # #[macro_use]
20//! # extern crate rocket;
21//! use rocket_sentry::RocketSentry;
22//!
23//! # fn main() {
24//! #[launch]
25//! fn rocket() -> _ {
26//!     rocket::build()
27//!         .attach(RocketSentry::fairing())
28//!         // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   add this line
29//! }
30//! # }
31//! ```
32//!
33//! Then, the Sentry integration can be enabled by adding a `sentry_dsn=` value to
34//! the `Rocket.toml` file, for example:
35//!
36//! ```toml
37//! [debug]
38//! sentry_dsn = ""  # Disabled
39//! [release]
40//! sentry_dsn = "https://057006d7dfe5fff0fbed461cfca5f757@sentry.io/1111111"
41//! sentry_traces_sample_rate = 0.2  # 20% of requests will be logged under the performance tab
42//! ```
43//!
44#[macro_use]
45extern crate log;
46
47use std::borrow::Cow;
48use std::collections::BTreeMap;
49use std::sync::atomic::{AtomicBool, Ordering};
50use std::sync::{Arc, Mutex};
51
52use rocket::fairing::{Fairing, Info, Kind};
53use rocket::http::Status;
54use rocket::request::local_cache_once;
55use rocket::serde::Deserialize;
56use rocket::{fairing, Build, Data, Request, Response, Rocket};
57use sentry::protocol::SpanStatus;
58use sentry::{protocol, ClientInitGuard, ClientOptions, Hub, TracesSampler, Transaction};
59
60const TRANSACTION_OPERATION_NAME: &str = "http.server";
61
62pub struct RocketSentry {
63    guard: Mutex<Option<ClientInitGuard>>,
64    transactions_enabled: AtomicBool,
65    traces_sampler: Option<Arc<TracesSampler>>,
66}
67
68#[derive(Deserialize)]
69struct Config {
70    sentry_dsn: String,
71    sentry_traces_sample_rate: Option<f32>, // Default is 0 so no transaction transmitted
72}
73
74impl RocketSentry {
75    #[must_use]
76    pub fn fairing() -> impl Fairing {
77        RocketSentry::builder().build()
78    }
79
80    #[must_use]
81    pub fn builder() -> RocketSentryBuilder {
82        RocketSentryBuilder::new()
83    }
84
85    fn init(&self, dsn: &str, traces_sample_rate: f32, environment: Cow<'static, str>) {
86        let guard = sentry::init((
87            dsn,
88            ClientOptions {
89                before_send: Some(Arc::new(|event| {
90                    info!("Sending event to Sentry: {}", event.event_id);
91                    Some(event)
92                })),
93                traces_sample_rate,
94                traces_sampler: self.traces_sampler.clone(),
95                environment: Some(environment),
96                ..Default::default()
97            },
98        ));
99
100        if guard.is_enabled() {
101            // Tuck the ClientInitGuard in the fairing, so it lives as long as the server.
102            let mut self_guard = self.guard.lock().unwrap();
103            *self_guard = Some(guard);
104
105            info!("Sentry enabled.");
106            if traces_sample_rate > 0f32 || self.traces_sampler.is_some() {
107                self.transactions_enabled.store(true, Ordering::Relaxed);
108            }
109        } else {
110            error!("Sentry did not initialize.");
111        }
112    }
113
114    fn start_transaction(name: &str) -> Transaction {
115        let transaction_context = sentry::TransactionContext::new(name, TRANSACTION_OPERATION_NAME);
116        let transaction = sentry::start_transaction(transaction_context);
117        Hub::current().configure_scope(|scope| {
118            scope.set_span(Some(transaction.clone().into()));
119        });
120        transaction
121    }
122}
123
124#[rocket::async_trait]
125impl Fairing for RocketSentry {
126    fn info(&self) -> Info {
127        Info {
128            name: "rocket-sentry",
129            kind: Kind::Ignite | Kind::Singleton | Kind::Request | Kind::Response,
130        }
131    }
132
133    async fn on_ignite(&self, rocket: Rocket<Build>) -> fairing::Result {
134        let figment = rocket.figment();
135        let profile_name = figment.profile().to_string();
136
137        // Set Sentry's environment based on Rocket profile
138        let environment = match profile_name.as_str() {
139            "debug" => Cow::Borrowed("development"),
140            "release" => Cow::Borrowed("production"),
141            _ => Cow::Owned(profile_name),
142        };
143
144        let config: figment::error::Result<Config> = figment.extract();
145        match config {
146            Ok(config) => {
147                if config.sentry_dsn.is_empty() {
148                    info!("Sentry disabled.");
149                } else {
150                    let traces_sample_rate = config.sentry_traces_sample_rate.unwrap_or(0f32);
151                    self.init(&config.sentry_dsn, traces_sample_rate, environment);
152                }
153            }
154            Err(err) => error!("Sentry not configured: {err}"),
155        }
156        Ok(rocket)
157    }
158
159    async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
160        if self.transactions_enabled.load(Ordering::Relaxed) {
161            let name = request_to_transaction_name(request);
162            let build_transaction = move || Some(Self::start_transaction(&name));
163            let request_transaction = local_cache_once!(request, build_transaction);
164            request.local_cache(request_transaction);
165        }
166    }
167
168    async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
169        if self.transactions_enabled.load(Ordering::Relaxed) {
170            // We take the transaction set in the on_request callback
171            if let Some(ongoing_transaction) = get_current_transaction(request) {
172                ongoing_transaction.set_status(map_status(response.status()));
173                set_transaction_request(ongoing_transaction, request);
174                ongoing_transaction.clone().finish();
175            }
176        }
177    }
178}
179
180fn get_current_transaction<'r>(request: &'r Request) -> Option<&'r Transaction> {
181    fn no_transaction() -> Option<Transaction> {
182        // mimic the function signature expected by the cache
183        None
184    }
185
186    let request_transaction = local_cache_once!(request, no_transaction);
187    let ongoing_transaction = request.local_cache(request_transaction);
188    ongoing_transaction.as_ref()
189}
190
191fn set_transaction_request(transaction: &Transaction, request: &Request) {
192    transaction.set_request(protocol::Request {
193        url: None,
194        method: Some(request.method().to_string()),
195        data: None,
196        query_string: request_to_query_string(request),
197        cookies: None,
198        headers: request_to_header_map(request),
199        env: BTreeMap::new(),
200    });
201}
202
203fn request_to_transaction_name(request: &Request) -> String {
204    let method = request.method();
205    let path = request.uri().path();
206    format!("{method} {path}")
207}
208
209fn request_to_query_string(request: &Request) -> Option<String> {
210    Some(request.uri().query()?.to_string())
211}
212
213fn map_status(status: Status) -> SpanStatus {
214    #[allow(clippy::match_same_arms)]
215    match status.code {
216        100..=299 => SpanStatus::Ok,
217        // For 3xx there is no appropriate redirect status, so we default to Ok as flask does,
218        // https://github.com/getsentry/sentry-python/blob/e0d7bb733b5db43531b1efae431669bfe9e63908/sentry_sdk/tracing.py#L408-L435
219        300..=399 => SpanStatus::Ok,
220        401 => SpanStatus::Unauthenticated,
221        403 => SpanStatus::PermissionDenied,
222        404 => SpanStatus::NotFound,
223        409 => SpanStatus::AlreadyExists,
224        429 => SpanStatus::ResourceExhausted,
225        400..=499 => SpanStatus::InvalidArgument,
226        501 => SpanStatus::Unimplemented,
227        503 => SpanStatus::Unavailable,
228        500..=599 => SpanStatus::InternalError,
229        _ => SpanStatus::UnknownError,
230    }
231}
232
233fn request_to_header_map(request: &Request) -> BTreeMap<String, String> {
234    request
235        .headers()
236        .iter()
237        .map(|header| (header.name().to_string(), header.value().to_string()))
238        .collect()
239}
240
241pub struct RocketSentryBuilder {
242    traces_sampler: Option<Arc<TracesSampler>>,
243}
244
245impl RocketSentryBuilder {
246    #[must_use]
247    fn new() -> RocketSentryBuilder {
248        RocketSentryBuilder {
249            traces_sampler: None,
250        }
251    }
252
253    #[must_use]
254    pub fn traces_sampler(mut self, traces_sampler: Arc<TracesSampler>) -> RocketSentryBuilder {
255        self.traces_sampler = Some(traces_sampler);
256        self
257    }
258
259    #[must_use]
260    pub fn build(self) -> RocketSentry {
261        RocketSentry {
262            guard: Mutex::new(None),
263            transactions_enabled: AtomicBool::new(false),
264            traces_sampler: self.traces_sampler,
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use rocket::http::ContentType;
272    use rocket::http::Header;
273    use rocket::local::asynchronous::Client;
274    use sentry::TransactionContext;
275    use std::borrow::Cow;
276    use std::sync::atomic::Ordering;
277    use std::sync::Arc;
278
279    use crate::{
280        request_to_header_map, request_to_query_string, request_to_transaction_name, RocketSentry,
281    };
282
283    const DEFAULT_ENV: Cow<'static, str> = Cow::Borrowed("TEST");
284
285    #[rocket::async_test]
286    async fn request_to_sentry_transaction_name_get_no_path() {
287        let rocket = rocket::build();
288        let client = Client::tracked(rocket).await.unwrap();
289        let request = client.get("/");
290
291        let transaction_name = request_to_transaction_name(request.inner());
292
293        assert_eq!(transaction_name, "GET /");
294    }
295
296    #[rocket::async_test]
297    async fn request_to_sentry_transaction_name_get_some_path() {
298        let rocket = rocket::build();
299        let client = Client::tracked(rocket).await.unwrap();
300        let request = client.get("/some/path");
301
302        let transaction_name = request_to_transaction_name(request.inner());
303
304        assert_eq!(transaction_name, "GET /some/path");
305    }
306
307    #[rocket::async_test]
308    async fn request_to_sentry_transaction_name_post_path_with_variables() {
309        let rocket = rocket::build();
310        let client = Client::tracked(rocket).await.unwrap();
311        let request = client.post("/users/6");
312
313        let transaction_name = request_to_transaction_name(request.inner());
314
315        // Ideally, we should just returns /users/<id> as configured in the routes
316        assert_eq!(transaction_name, "POST /users/6");
317    }
318
319    #[rocket::async_test]
320    async fn request_to_query_string_is_none() {
321        let rocket = rocket::build();
322        let client = Client::tracked(rocket).await.unwrap();
323        let request = client.post("/");
324
325        let query_string = request_to_query_string(request.inner());
326
327        assert_eq!(query_string, None);
328    }
329
330    #[rocket::async_test]
331    async fn request_to_query_string_single_parameter() {
332        let rocket = rocket::build();
333        let client = Client::tracked(rocket).await.unwrap();
334        let request = client.post("/?param1=value1");
335
336        let query_string = request_to_query_string(request.inner());
337
338        assert_eq!(query_string, Some("param1=value1".to_string()));
339    }
340
341    #[rocket::async_test]
342    async fn request_to_query_string_multiple_parameters() {
343        let rocket = rocket::build();
344        let client = Client::tracked(rocket).await.unwrap();
345        let request = client.post("/?param1=value1&param2=value2");
346
347        let query_string = request_to_query_string(request.inner());
348
349        assert_eq!(
350            query_string,
351            Some("param1=value1&param2=value2".to_string())
352        );
353    }
354
355    #[rocket::async_test]
356    async fn request_to_header_map_is_empty() {
357        let rocket = rocket::build();
358        let client = Client::tracked(rocket).await.unwrap();
359        let request = client.get("/");
360
361        let header_map = request_to_header_map(request.inner());
362
363        assert!(header_map.is_empty());
364    }
365
366    #[rocket::async_test]
367    async fn request_to_header_map_multiple() {
368        let rocket = rocket::build();
369        let client = Client::tracked(rocket).await.unwrap();
370        let request = client
371            .get("/")
372            .header(ContentType::JSON)
373            .header(Header::new("custom-key", "custom-value"));
374
375        let header_map = request_to_header_map(request.inner());
376
377        assert_eq!(
378            header_map.get("custom-key"),
379            Some(&"custom-value".to_string())
380        );
381        assert_eq!(
382            header_map.get("Content-Type"),
383            Some(&"application/json".to_string())
384        );
385    }
386
387    /// Transaction are only enabled on positive `traces_sample_rate` or a set `traces_sampler`
388    #[rocket::async_test]
389    async fn transactions_not_enabled() {
390        let rocket_sentry = RocketSentry::builder().build();
391
392        rocket_sentry.init("https://user@some.dsn/123", 0., DEFAULT_ENV);
393
394        assert!(!rocket_sentry.transactions_enabled.load(Ordering::Relaxed));
395    }
396
397    #[rocket::async_test]
398    async fn transactions_enabled_by_traces_sample_rate() {
399        let rocket_sentry = RocketSentry::builder().build();
400
401        rocket_sentry.init("https://user@some.dsn/123", 0.01, DEFAULT_ENV);
402
403        assert!(rocket_sentry.transactions_enabled.load(Ordering::Relaxed));
404    }
405
406    #[rocket::async_test]
407    async fn transactions_enabled_by_traces_sampler() {
408        let rocket_sentry = RocketSentry::builder()
409            .traces_sampler(Arc::new(move |_: &TransactionContext| -> f32 {
410                0. // Even a sampler that deny all transaction will mark transactions as enabled
411            }))
412            .build();
413
414        rocket_sentry.init("https://user@some.dsn/123", 0., DEFAULT_ENV);
415
416        assert!(rocket_sentry.transactions_enabled.load(Ordering::Relaxed));
417    }
418}