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