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 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 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 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 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 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 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 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¶m2=value2");
347
348 let query_string = request_to_query_string(request.inner());
349
350 assert_eq!(
351 query_string,
352 Some("param1=value1¶m2=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 #[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. }))
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}