1#![warn(clippy::pedantic)]
2#![warn(clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5#[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>, }
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 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 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 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 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 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 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¶m2=value2");
346
347 let query_string = request_to_query_string(request.inner());
348
349 assert_eq!(
350 query_string,
351 Some("param1=value1¶m2=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 #[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. }))
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}