1#![doc(html_favicon_url = "https://sentry-brand.storage.googleapis.com/favicon.ico")]
72#![doc(html_logo_url = "https://sentry-brand.storage.googleapis.com/sentry-glyph-black.png")]
73#![warn(missing_docs)]
74#![allow(deprecated)]
75#![allow(clippy::type_complexity)]
76
77use std::borrow::Cow;
78use std::pin::Pin;
79use std::rc::Rc;
80use std::sync::Arc;
81
82use actix_http::header::{self, HeaderMap};
83use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
84use actix_web::http::StatusCode;
85use actix_web::Error;
86use bytes::{Bytes, BytesMut};
87use futures_util::future::{ok, Future, Ready};
88use futures_util::{FutureExt as _, TryStreamExt as _};
89
90use sentry_core::protocol::{self, ClientSdkPackage, Event, Request};
91use sentry_core::utils::{is_sensitive_header, scrub_pii_from_url};
92use sentry_core::MaxRequestBodySize;
93use sentry_core::{Hub, SentryFutureExt};
94
95pub struct SentryBuilder {
97 middleware: Sentry,
98}
99
100impl SentryBuilder {
101 pub fn finish(self) -> Sentry {
103 self.middleware
104 }
105
106 #[must_use]
108 pub fn start_transaction(mut self, start_transaction: bool) -> Self {
109 self.middleware.start_transaction = start_transaction;
110 self
111 }
112
113 #[must_use]
115 pub fn with_hub(mut self, hub: Arc<Hub>) -> Self {
116 self.middleware.hub = Some(hub);
117 self
118 }
119
120 #[must_use]
122 pub fn with_default_hub(mut self) -> Self {
123 self.middleware.hub = None;
124 self
125 }
126
127 #[must_use]
129 pub fn emit_header(mut self, val: bool) -> Self {
130 self.middleware.emit_header = val;
131 self
132 }
133
134 #[must_use]
138 pub fn capture_server_errors(mut self, val: bool) -> Self {
139 self.middleware.capture_server_errors = val;
140 self
141 }
142}
143
144#[derive(Clone)]
146pub struct Sentry {
147 hub: Option<Arc<Hub>>,
148 emit_header: bool,
149 capture_server_errors: bool,
150 start_transaction: bool,
151}
152
153impl Sentry {
154 pub fn new() -> Self {
156 Sentry {
157 hub: None,
158 emit_header: false,
159 capture_server_errors: true,
160 start_transaction: false,
161 }
162 }
163
164 pub fn with_transaction() -> Sentry {
166 Sentry {
167 start_transaction: true,
168 ..Sentry::default()
169 }
170 }
171
172 pub fn builder() -> SentryBuilder {
174 Sentry::new().into_builder()
175 }
176
177 pub fn into_builder(self) -> SentryBuilder {
179 SentryBuilder { middleware: self }
180 }
181}
182
183impl Default for Sentry {
184 fn default() -> Self {
185 Sentry::new()
186 }
187}
188
189impl<S, B> Transform<S, ServiceRequest> for Sentry
190where
191 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
192 S::Future: 'static,
193{
194 type Response = ServiceResponse<B>;
195 type Error = Error;
196 type Transform = SentryMiddleware<S>;
197 type InitError = ();
198 type Future = Ready<Result<Self::Transform, Self::InitError>>;
199
200 fn new_transform(&self, service: S) -> Self::Future {
201 ok(SentryMiddleware {
202 service: Rc::new(service),
203 inner: self.clone(),
204 })
205 }
206}
207
208pub struct SentryMiddleware<S> {
210 service: Rc<S>,
211 inner: Sentry,
212}
213
214fn should_capture_request_body(
215 headers: &HeaderMap,
216 with_pii: bool,
217 max_request_body_size: MaxRequestBodySize,
218) -> bool {
219 let is_chunked = headers
220 .get(header::TRANSFER_ENCODING)
221 .and_then(|h| h.to_str().ok())
222 .map(|transfer_encoding| transfer_encoding.contains("chunked"))
223 .unwrap_or(false);
224
225 let is_valid_content_type = with_pii
226 || headers
227 .get(header::CONTENT_TYPE)
228 .and_then(|h| h.to_str().ok())
229 .is_some_and(|content_type| {
230 matches!(
231 content_type,
232 "application/json" | "application/x-www-form-urlencoded"
233 )
234 });
235
236 let is_within_size_limit = headers
237 .get(header::CONTENT_LENGTH)
238 .and_then(|h| h.to_str().ok())
239 .and_then(|content_length| content_length.parse::<usize>().ok())
240 .map(|content_length| max_request_body_size.is_within_size_limit(content_length))
241 .unwrap_or(false);
242
243 !is_chunked && is_valid_content_type && is_within_size_limit
244}
245
246async fn body_from_http(req: &mut ServiceRequest) -> actix_web::Result<Bytes> {
248 let stream = req.extract::<actix_web::web::Payload>().await?;
249 let body = stream.try_collect::<BytesMut>().await?.freeze();
250
251 req.set_payload(actix_web::dev::Payload::from(body.clone()));
253
254 Ok(body)
255}
256
257async fn capture_request_body(req: &mut ServiceRequest) -> String {
258 match body_from_http(req).await {
259 Ok(request_body) => String::from_utf8_lossy(&request_body).into_owned(),
260 Err(_) => String::new(),
261 }
262}
263
264impl<S, B> Service<ServiceRequest> for SentryMiddleware<S>
265where
266 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
267 S::Future: 'static,
268{
269 type Response = ServiceResponse<B>;
270 type Error = Error;
271 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
272
273 fn poll_ready(
274 &self,
275 cx: &mut std::task::Context<'_>,
276 ) -> std::task::Poll<Result<(), Self::Error>> {
277 self.service.poll_ready(cx)
278 }
279
280 fn call(&self, req: ServiceRequest) -> Self::Future {
281 let inner = self.inner.clone();
282 let hub = Arc::new(Hub::new_from_top(
283 inner.hub.clone().unwrap_or_else(Hub::main),
284 ));
285
286 let client = hub.client();
287
288 let max_request_body_size = client
289 .as_ref()
290 .map(|client| client.options().max_request_body_size)
291 .unwrap_or(MaxRequestBodySize::None);
292
293 #[cfg(feature = "release-health")]
294 {
295 let track_sessions = client.as_ref().is_some_and(|client| {
296 let options = client.options();
297 options.auto_session_tracking
298 && options.session_mode == sentry_core::SessionMode::Request
299 });
300 if track_sessions {
301 hub.start_session();
302 }
303 }
304
305 let with_pii = client
306 .as_ref()
307 .is_some_and(|client| client.options().send_default_pii);
308
309 let mut sentry_req = sentry_request_from_http(&req, with_pii);
310 let name = transaction_name_from_http(&req);
311
312 let transaction = if inner.start_transaction {
313 let headers = req.headers().iter().flat_map(|(header, value)| {
314 value.to_str().ok().map(|value| (header.as_str(), value))
315 });
316
317 let ctx = sentry_core::TransactionContext::continue_from_headers(
318 &name,
319 "http.server",
320 headers,
321 );
322
323 let transaction = hub.start_transaction(ctx);
324 transaction.set_request(sentry_req.clone());
325 transaction.set_origin("auto.http.actix");
326 Some(transaction)
327 } else {
328 None
329 };
330
331 let svc = self.service.clone();
332 async move {
333 let mut req = req;
334
335 if should_capture_request_body(req.headers(), with_pii, max_request_body_size) {
336 sentry_req.data = Some(capture_request_body(&mut req).await);
337 }
338
339 let parent_span = hub.configure_scope(|scope| {
340 let parent_span = scope.get_span();
341 if let Some(transaction) = transaction.as_ref() {
342 scope.set_span(Some(transaction.clone().into()));
343 } else {
344 scope.set_transaction((!inner.start_transaction).then_some(&name));
345 }
346 scope.add_event_processor(move |event| Some(process_event(event, &sentry_req)));
347 parent_span
348 });
349
350 let fut = Hub::run(hub.clone(), || svc.call(req)).bind_hub(hub.clone());
351 let mut res: Self::Response = match fut.await {
352 Ok(res) => res,
353 Err(e) => {
354 if inner.capture_server_errors && e.error_response().status().is_server_error()
356 {
357 hub.capture_error(&e);
358 }
359
360 if let Some(transaction) = transaction {
361 if transaction.get_status().is_none() {
362 let status = protocol::SpanStatus::UnknownError;
363 transaction.set_status(status);
364 }
365 transaction.finish();
366 hub.configure_scope(|scope| scope.set_span(parent_span));
367 }
368 return Err(e);
369 }
370 };
371
372 if inner.capture_server_errors && res.response().status().is_server_error() {
374 if let Some(e) = res.response().error() {
375 let event_id = hub.capture_error(e);
376
377 if inner.emit_header {
378 res.response_mut().headers_mut().insert(
379 "x-sentry-event".parse().unwrap(),
380 event_id.simple().to_string().parse().unwrap(),
381 );
382 }
383 }
384 }
385
386 if let Some(transaction) = transaction {
387 if transaction.get_status().is_none() {
388 let status = map_status(res.status());
389 transaction.set_status(status);
390 }
391 transaction.finish();
392 hub.configure_scope(|scope| scope.set_span(parent_span));
393 }
394
395 Ok(res)
396 }
397 .boxed_local()
398 }
399}
400
401fn map_status(status: StatusCode) -> protocol::SpanStatus {
402 match status {
403 StatusCode::UNAUTHORIZED => protocol::SpanStatus::Unauthenticated,
404 StatusCode::FORBIDDEN => protocol::SpanStatus::PermissionDenied,
405 StatusCode::NOT_FOUND => protocol::SpanStatus::NotFound,
406 StatusCode::TOO_MANY_REQUESTS => protocol::SpanStatus::ResourceExhausted,
407 status if status.is_client_error() => protocol::SpanStatus::InvalidArgument,
408 StatusCode::NOT_IMPLEMENTED => protocol::SpanStatus::Unimplemented,
409 StatusCode::SERVICE_UNAVAILABLE => protocol::SpanStatus::Unavailable,
410 status if status.is_server_error() => protocol::SpanStatus::InternalError,
411 StatusCode::CONFLICT => protocol::SpanStatus::AlreadyExists,
412 status if status.is_success() => protocol::SpanStatus::Ok,
413 _ => protocol::SpanStatus::UnknownError,
414 }
415}
416
417fn transaction_name_from_http(req: &ServiceRequest) -> String {
419 let path_part = req.match_pattern().unwrap_or_else(|| "<none>".to_string());
420 format!("{} {}", req.method(), path_part)
421}
422
423fn sentry_request_from_http(request: &ServiceRequest, with_pii: bool) -> Request {
425 let mut sentry_req = Request {
426 url: format!(
427 "{}://{}{}",
428 request.connection_info().scheme(),
429 request.connection_info().host(),
430 request.uri()
431 )
432 .parse()
433 .ok()
434 .map(scrub_pii_from_url),
435 method: Some(request.method().to_string()),
436 headers: request
437 .headers()
438 .iter()
439 .filter(|(_, v)| !v.is_sensitive())
440 .filter(|(k, _)| with_pii || !is_sensitive_header(k.as_str()))
441 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default().to_string()))
442 .collect(),
443 ..Default::default()
444 };
445
446 if with_pii {
448 if let Some(remote) = request.connection_info().remote_addr() {
449 sentry_req.env.insert("REMOTE_ADDR".into(), remote.into());
450 }
451 };
452
453 sentry_req
454}
455
456fn process_event(mut event: Event<'static>, request: &Request) -> Event<'static> {
458 if event.request.is_none() {
460 event.request = Some(request.clone());
461 }
462
463 if let Some(sdk) = event.sdk.take() {
465 let mut sdk = sdk.into_owned();
466 sdk.packages.push(ClientSdkPackage {
467 name: "sentry-actix".into(),
468 version: env!("CARGO_PKG_VERSION").into(),
469 });
470 event.sdk = Some(Cow::Owned(sdk));
471 }
472 event
473}
474
475#[cfg(test)]
476mod tests {
477 use std::io;
478
479 use actix_web::body::BoxBody;
480 use actix_web::test::{call_service, init_service, TestRequest};
481 use actix_web::{get, web, App, HttpRequest, HttpResponse};
482 use futures::executor::block_on;
483
484 use futures::future::join_all;
485 use sentry::Level;
486
487 use super::*;
488
489 fn _assert_hub_no_events() {
490 if Hub::current().last_event_id().is_some() {
491 panic!("Current hub should not have had any events.");
492 }
493 }
494
495 fn _assert_hub_has_events() {
496 Hub::current()
497 .last_event_id()
498 .expect("Current hub should have had events.");
499 }
500
501 #[actix_web::test]
503 async fn test_explicit_events() {
504 let events = sentry::test::with_captured_events(|| {
505 block_on(async {
506 let service = || {
507 _assert_hub_no_events();
509
510 sentry::capture_message("Message", Level::Warning);
511
512 _assert_hub_has_events();
514
515 HttpResponse::Ok()
516 };
517
518 let app = init_service(
519 App::new()
520 .wrap(Sentry::builder().with_hub(Hub::current()).finish())
521 .service(web::resource("/test").to(service)),
522 )
523 .await;
524
525 for _ in 0..2 {
527 let req = TestRequest::get().uri("/test").to_request();
528 let res = call_service(&app, req).await;
529 assert!(res.status().is_success());
530 }
531 })
532 });
533
534 assert_eq!(events.len(), 2);
535 for event in events {
536 let request = event.request.expect("Request should be set.");
537 assert_eq!(event.transaction, Some("GET /test".into()));
538 assert_eq!(event.message, Some("Message".into()));
539 assert_eq!(event.level, Level::Warning);
540 assert_eq!(request.method, Some("GET".into()));
541 }
542 }
543
544 #[actix_web::test]
546 async fn test_match_pattern() {
547 let events = sentry::test::with_captured_events(|| {
548 block_on(async {
549 let service = |_name: String| {
550 _assert_hub_no_events();
552
553 sentry::capture_message("Message", Level::Warning);
554
555 _assert_hub_has_events();
557
558 HttpResponse::Ok()
559 };
560
561 let app = init_service(
562 App::new()
563 .wrap(Sentry::builder().with_hub(Hub::current()).finish())
564 .service(web::resource("/test/{name}").route(web::post().to(service))),
565 )
566 .await;
567
568 for _ in 0..2 {
570 let req = TestRequest::post().uri("/test/fake_name").to_request();
571 let res = call_service(&app, req).await;
572 assert!(res.status().is_success());
573 }
574 })
575 });
576
577 assert_eq!(events.len(), 2);
578 for event in events {
579 let request = event.request.expect("Request should be set.");
580 assert_eq!(event.transaction, Some("POST /test/{name}".into()));
581 assert_eq!(event.message, Some("Message".into()));
582 assert_eq!(event.level, Level::Warning);
583 assert_eq!(request.method, Some("POST".into()));
584 }
585 }
586
587 #[actix_web::test]
589 async fn test_response_errors() {
590 let events = sentry::test::with_captured_events(|| {
591 block_on(async {
592 #[get("/test")]
593 async fn failing(_req: HttpRequest) -> Result<String, Error> {
594 _assert_hub_no_events();
596
597 Err(io::Error::other("Test Error").into())
598 }
599
600 let app = init_service(
601 App::new()
602 .wrap(Sentry::builder().with_hub(Hub::current()).finish())
603 .service(failing),
604 )
605 .await;
606
607 for _ in 0..2 {
609 let req = TestRequest::get().uri("/test").to_request();
610 let res = call_service(&app, req).await;
611 assert!(res.status().is_server_error());
612 }
613 })
614 });
615
616 assert_eq!(events.len(), 2);
617 for event in events {
618 let request = event.request.expect("Request should be set.");
619 assert_eq!(event.transaction, Some("GET /test".into())); assert_eq!(event.message, None);
621 assert_eq!(event.exception.values[0].ty, String::from("Custom"));
622 assert_eq!(event.exception.values[0].value, Some("Test Error".into()));
623 assert_eq!(event.level, Level::Error);
624 assert_eq!(request.method, Some("GET".into()));
625 }
626 }
627
628 #[actix_web::test]
630 async fn test_service_client_errors_discarded() {
631 let events = sentry::test::with_captured_events(|| {
632 block_on(async {
633 let service = HttpResponse::NotFound;
634
635 let app = init_service(
636 App::new()
637 .wrap(Sentry::builder().with_hub(Hub::current()).finish())
638 .service(web::resource("/test").to(service)),
639 )
640 .await;
641
642 let req = TestRequest::get().uri("/test").to_request();
643 let res = call_service(&app, req).await;
644 assert!(res.status().is_client_error());
645 })
646 });
647
648 assert!(events.is_empty());
649 }
650
651 #[actix_web::test]
653 async fn test_middleware_client_errors_discarded() {
654 let events = sentry::test::with_captured_events(|| {
655 block_on(async {
656 async fn hello_world() -> HttpResponse {
657 HttpResponse::Ok().body("Hello, world!")
658 }
659
660 let app = init_service(
661 App::new()
662 .wrap_fn(|_, _| async {
663 Err(actix_web::error::ErrorNotFound("Not found"))
664 as Result<ServiceResponse<BoxBody>, _>
665 })
666 .wrap(Sentry::builder().with_hub(Hub::current()).finish())
667 .service(web::resource("/test").to(hello_world)),
668 )
669 .await;
670
671 let req = TestRequest::get().uri("/test").to_request();
672 let res = app.call(req).await;
673 assert!(res.is_err());
674 assert!(res.unwrap_err().error_response().status().is_client_error());
675 })
676 });
677
678 assert!(events.is_empty());
679 }
680
681 #[actix_web::test]
683 async fn test_middleware_server_errors_captured() {
684 let events = sentry::test::with_captured_events(|| {
685 block_on(async {
686 async fn hello_world() -> HttpResponse {
687 HttpResponse::Ok().body("Hello, world!")
688 }
689
690 let app = init_service(
691 App::new()
692 .wrap_fn(|_, _| async {
693 Err(actix_web::error::ErrorInternalServerError("Server error"))
694 as Result<ServiceResponse<BoxBody>, _>
695 })
696 .wrap(Sentry::builder().with_hub(Hub::current()).finish())
697 .service(web::resource("/test").to(hello_world)),
698 )
699 .await;
700
701 let req = TestRequest::get().uri("/test").to_request();
702 let res = app.call(req).await;
703 assert!(res.is_err());
704 assert!(res.unwrap_err().error_response().status().is_server_error());
705 })
706 });
707
708 assert_eq!(events.len(), 1);
709 }
710
711 #[actix_web::test]
713 async fn test_override_transaction_name() {
714 let events = sentry::test::with_captured_events(|| {
715 block_on(async {
716 #[get("/test")]
717 async fn original_transaction(_req: HttpRequest) -> Result<String, Error> {
718 sentry::configure_scope(|scope| scope.set_transaction(Some("new_transaction")));
720 Err(io::Error::other("Test Error").into())
721 }
722
723 let app = init_service(
724 App::new()
725 .wrap(Sentry::builder().with_hub(Hub::current()).finish())
726 .service(original_transaction),
727 )
728 .await;
729
730 let req = TestRequest::get().uri("/test").to_request();
731 let res = call_service(&app, req).await;
732 assert!(res.status().is_server_error());
733 })
734 });
735
736 assert_eq!(events.len(), 1);
737 let event = events[0].clone();
738 let request = event.request.expect("Request should be set.");
739 assert_eq!(event.transaction, Some("new_transaction".into())); assert_eq!(event.message, None);
741 assert_eq!(event.exception.values[0].ty, String::from("Custom"));
742 assert_eq!(event.exception.values[0].value, Some("Test Error".into()));
743 assert_eq!(event.level, Level::Error);
744 assert_eq!(request.method, Some("GET".into()));
745 }
746
747 #[cfg(feature = "release-health")]
748 #[actix_web::test]
749 async fn test_track_session() {
750 let envelopes = sentry::test::with_captured_envelopes_options(
751 || {
752 block_on(async {
753 #[get("/")]
754 async fn hello() -> impl actix_web::Responder {
755 String::from("Hello there!")
756 }
757
758 let middleware = Sentry::builder().with_hub(Hub::current()).finish();
759
760 let app = init_service(App::new().wrap(middleware).service(hello)).await;
761
762 for _ in 0..5 {
763 let req = TestRequest::get().uri("/").to_request();
764 call_service(&app, req).await;
765 }
766 })
767 },
768 sentry::ClientOptions {
769 release: Some("some-release".into()),
770 session_mode: sentry::SessionMode::Request,
771 auto_session_tracking: true,
772 ..Default::default()
773 },
774 );
775 assert_eq!(envelopes.len(), 1);
776
777 let mut items = envelopes[0].items();
778 if let Some(sentry::protocol::EnvelopeItem::SessionAggregates(aggregate)) = items.next() {
779 let aggregates = &aggregate.aggregates;
780
781 assert_eq!(aggregates[0].distinct_id, None);
782 assert_eq!(aggregates[0].exited, 5);
783 } else {
784 panic!("expected session");
785 }
786 assert_eq!(items.next(), None);
787 }
788
789 #[actix_web::test]
792 async fn test_middleware_and_handler_use_correct_hub() {
793 sentry::test::with_captured_events(|| {
794 block_on(async {
795 sentry::capture_message("message outside", Level::Error);
796
797 let handler = || {
798 assert!(Hub::current().last_event_id().is_some());
800 sentry::capture_message("second message", Level::Error);
801 HttpResponse::Ok()
802 };
803
804 let app = init_service(
805 App::new()
806 .wrap_fn(|req, srv| {
807 assert!(Hub::current().last_event_id().is_none());
809
810 let event_id = sentry::capture_message("first message", Level::Error);
811
812 srv.call(req).map(move |res| {
813 assert!(Hub::current().last_event_id().is_some());
815 assert_ne!(Some(event_id), Hub::current().last_event_id());
816 res
817 })
818 })
819 .wrap(Sentry::builder().with_hub(Hub::current()).finish())
820 .service(web::resource("/test").to(handler)),
821 )
822 .await;
823
824 let mut futures = Vec::new();
826 for _ in 0..16 {
827 let req = TestRequest::get().uri("/test").to_request();
828 futures.push(call_service(&app, req));
829 }
830
831 join_all(futures).await;
832 })
833 });
834 }
835}