Skip to main content

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