1#![deny(missing_docs)]
2
3use std::{
10 collections::{HashMap, hash_map::DefaultHasher},
11 error::Error,
12 fmt,
13 hash::{Hash, Hasher},
14 marker::PhantomData,
15 sync::{
16 Arc, Mutex, MutexGuard,
17 atomic::{AtomicBool, Ordering},
18 },
19 time::{Duration, Instant},
20};
21
22use async_trait::async_trait;
23use http::Request;
24use nidus_core::{Container, LifecycleHook, NidusError};
25use sentry::{
26 Client, ClientInitGuard, ClientOptions, Hub, Level, MaxRequestBodySize,
27 protocol::{Breadcrumb, Event},
28};
29use sentry_tower::{HubProvider, SentryHttpLayer, SentryLayer};
30use tower::Layer;
31use tracing_subscriber::registry::LookupSpan;
32
33const MAX_DEDUP_ENTRIES: usize = 16_384;
34
35pub type Result<T> = std::result::Result<T, SentryError>;
37
38#[derive(Debug, thiserror::Error)]
40pub enum SentryError {
41 #[error("invalid Sentry configuration: {0}")]
43 Configuration(String),
44 #[error("Sentry lifecycle task failed: {0}")]
46 TaskJoin(String),
47 #[error("Sentry transport did not flush before the configured timeout")]
49 FlushTimeout,
50}
51
52#[derive(Clone)]
54pub struct SentryConfig {
55 dsn: String,
56 release: Option<String>,
57 environment: Option<String>,
58 event_sample_rate: f32,
59 trace_sample_rate: f32,
60 shutdown_timeout: Duration,
61 dedup_window: Duration,
62 dedup_capacity: usize,
63 allow_insecure_local_dsn: bool,
64}
65
66impl SentryConfig {
67 pub fn new(dsn: impl Into<String>) -> Self {
69 Self {
70 dsn: dsn.into(),
71 release: None,
72 environment: None,
73 event_sample_rate: 1.0,
74 trace_sample_rate: 0.1,
75 shutdown_timeout: Duration::from_secs(2),
76 dedup_window: Duration::from_secs(5),
77 dedup_capacity: 2_048,
78 allow_insecure_local_dsn: false,
79 }
80 }
81
82 pub fn from_env() -> Result<Self> {
84 let dsn = std::env::var("SENTRY_DSN")
85 .map_err(|_| SentryError::Configuration("SENTRY_DSN is required".to_owned()))?;
86 Ok(Self::new(dsn))
87 }
88
89 pub fn with_release(mut self, release: impl Into<String>) -> Self {
91 self.release = Some(release.into());
92 self
93 }
94
95 pub fn with_environment(mut self, environment: impl Into<String>) -> Self {
97 self.environment = Some(environment.into());
98 self
99 }
100
101 pub fn with_sample_rates(mut self, events: f32, traces: f32) -> Result<Self> {
103 if !events.is_finite()
104 || !traces.is_finite()
105 || !(0.0..=1.0).contains(&events)
106 || !(0.0..=1.0).contains(&traces)
107 {
108 return Err(SentryError::Configuration(
109 "sample rates must be between 0 and 1".to_owned(),
110 ));
111 }
112 self.event_sample_rate = events;
113 self.trace_sample_rate = traces;
114 Ok(self)
115 }
116
117 pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Result<Self> {
119 if timeout.is_zero() || timeout > Duration::from_secs(60) {
120 return Err(SentryError::Configuration(
121 "shutdown timeout must be between 1 millisecond and 60 seconds".to_owned(),
122 ));
123 }
124 self.shutdown_timeout = timeout;
125 Ok(self)
126 }
127
128 pub fn with_deduplication(mut self, window: Duration, capacity: usize) -> Result<Self> {
130 if window.is_zero()
131 || window > Duration::from_secs(300)
132 || capacity == 0
133 || capacity > MAX_DEDUP_ENTRIES
134 {
135 return Err(SentryError::Configuration(
136 "deduplication requires a 1ms..=300s window and 1..=16384 entries".to_owned(),
137 ));
138 }
139 self.dedup_window = window;
140 self.dedup_capacity = capacity;
141 Ok(self)
142 }
143
144 pub fn allow_insecure_local_dsn(mut self) -> Result<Self> {
146 let dsn = parse_dsn(&self.dsn)?;
147 if dsn.scheme().to_string() != "http" || !is_loopback_host(dsn.host()) {
148 return Err(SentryError::Configuration(
149 "insecure Sentry DSNs are restricted to loopback".to_owned(),
150 ));
151 }
152 self.allow_insecure_local_dsn = true;
153 Ok(self)
154 }
155
156 pub fn release(&self) -> Option<&str> {
158 self.release.as_deref()
159 }
160
161 pub fn environment(&self) -> Option<&str> {
163 self.environment.as_deref()
164 }
165
166 fn validate(&self) -> Result<sentry::types::Dsn> {
167 let dsn = parse_dsn(&self.dsn)?;
168 let scheme = dsn.scheme().to_string();
169 if scheme != "https"
170 && !(scheme == "http" && self.allow_insecure_local_dsn && is_loopback_host(dsn.host()))
171 {
172 return Err(SentryError::Configuration(
173 "Sentry DSN must use HTTPS unless loopback HTTP is explicitly enabled".to_owned(),
174 ));
175 }
176 Ok(dsn)
177 }
178
179 fn client_options(&self) -> Result<ClientOptions> {
180 let dsn = self.validate()?;
181 let deduplicator = Arc::new(EventDeduplicator::new(
182 self.dedup_window,
183 self.dedup_capacity,
184 ));
185 let before_send_deduplicator = Arc::clone(&deduplicator);
186
187 let mut options = ClientOptions::new()
188 .sample_rate(self.event_sample_rate)
189 .traces_sample_rate(self.trace_sample_rate)
190 .send_default_pii(false)
191 .attach_stacktrace(true)
192 .shutdown_timeout(self.shutdown_timeout)
193 .max_breadcrumbs(100)
194 .max_request_body_size(MaxRequestBodySize::None)
195 .before_send(move |event| {
196 let event = redact_event(event);
197 (!before_send_deduplicator.is_duplicate(&event)).then_some(event)
198 })
199 .before_breadcrumb(|mut breadcrumb| {
200 redact_breadcrumb(&mut breadcrumb);
201 Some(breadcrumb)
202 });
203 options.dsn = Some(dsn);
204 options.release = self.release.clone().map(Into::into);
205 options.environment = self.environment.clone().map(Into::into);
206 options.accept_invalid_certs = false;
207 options.send_default_pii = false;
208 options.enable_logs = false;
209 Ok(options)
210 }
211}
212
213impl fmt::Debug for SentryConfig {
214 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215 formatter
216 .debug_struct("SentryConfig")
217 .field("dsn", &"<redacted>")
218 .field("release", &self.release)
219 .field("environment", &self.environment)
220 .field("event_sample_rate", &self.event_sample_rate)
221 .field("trace_sample_rate", &self.trace_sample_rate)
222 .field("shutdown_timeout", &self.shutdown_timeout)
223 .field("dedup_window", &self.dedup_window)
224 .field("dedup_capacity", &self.dedup_capacity)
225 .field("allow_insecure_local_dsn", &self.allow_insecure_local_dsn)
226 .finish()
227 }
228}
229
230#[derive(Clone)]
232pub struct SentryIntegration {
233 inner: Arc<SentryInner>,
234}
235
236struct SentryInner {
237 hub: Arc<Hub>,
238 previous_client: Option<Arc<Client>>,
239 client: Arc<Client>,
240 guard: Mutex<Option<ClientInitGuard>>,
241 shutdown_timeout: Duration,
242 shutdown: AtomicBool,
243}
244
245impl SentryIntegration {
246 pub fn init(config: SentryConfig) -> Result<Self> {
251 let options = config.client_options()?;
252 let hub = Hub::current();
253 let previous_client = hub.client();
254 let guard = sentry::init(options);
255 let client = hub.client().ok_or_else(|| {
256 SentryError::Configuration(
257 "Sentry client was not bound after initialization".to_owned(),
258 )
259 })?;
260 Ok(Self {
261 inner: Arc::new(SentryInner {
262 hub,
263 previous_client,
264 client,
265 guard: Mutex::new(Some(guard)),
266 shutdown_timeout: config.shutdown_timeout,
267 shutdown: AtomicBool::new(false),
268 }),
269 })
270 }
271
272 pub fn client(&self) -> &Arc<Client> {
274 &self.inner.client
275 }
276
277 pub fn hub(&self) -> &Arc<Hub> {
279 &self.inner.hub
280 }
281
282 pub fn register(&self, container: &mut Container) -> nidus_core::Result<()> {
284 container.register_singleton(self.clone())
285 }
286
287 pub fn tower_layer<B>(&self) -> SentryTowerLayer<B> {
290 SentryTowerLayer::new(Arc::clone(&self.inner.hub))
291 }
292
293 pub fn tracing_layer<S>(&self) -> sentry_tracing::SentryLayer<S>
298 where
299 S: tracing::Subscriber + for<'span> LookupSpan<'span>,
300 {
301 sentry_tracing::layer()
302 .event_filter(|metadata| match *metadata.level() {
303 tracing::Level::ERROR => sentry_tracing::EventFilter::Event,
304 tracing::Level::WARN | tracing::Level::INFO => {
305 sentry_tracing::EventFilter::Breadcrumb
306 }
307 tracing::Level::DEBUG | tracing::Level::TRACE => {
308 sentry_tracing::EventFilter::Ignore
309 }
310 })
311 .span_filter(|metadata| {
312 matches!(
313 *metadata.level(),
314 tracing::Level::ERROR | tracing::Level::WARN | tracing::Level::INFO
315 )
316 })
317 }
318
319 pub fn capture_error<E>(&self, error: &E) -> sentry::types::Uuid
321 where
322 E: Error + ?Sized,
323 {
324 self.inner
325 .hub
326 .capture_event(sentry::event_from_error(error))
327 }
328
329 pub fn capture_message(&self, message: &str, level: Level) -> sentry::types::Uuid {
331 self.inner.hub.capture_message(message, level)
332 }
333
334 pub fn is_ready(&self) -> bool {
336 self.inner.client.is_enabled() && !self.inner.shutdown.load(Ordering::Acquire)
337 }
338
339 #[cfg(feature = "health")]
341 pub fn register_ready_check(
342 self: Arc<Self>,
343 registry: nidus_http::health::HealthRegistry,
344 name: impl Into<String>,
345 ) -> nidus_http::health::HealthRegistry {
346 registry.ready_check(name, move || {
347 let integration = Arc::clone(&self);
348 async move {
349 if integration.is_ready() {
350 nidus_http::health::HealthStatus::up()
351 } else {
352 nidus_http::health::HealthStatus::down("Sentry client is not ready")
353 }
354 }
355 })
356 }
357
358 #[cfg(feature = "dashboard")]
360 pub async fn record_dashboard_status(
361 &self,
362 collector: &nidus_dashboard::DashboardCollector<
363 nidus_dashboard::storage::DashboardStorageHandle,
364 >,
365 ) -> nidus_dashboard::Result<()> {
366 collector
367 .record_adapter("nidus-sentry.readiness", None, self.is_ready(), 0)
368 .await
369 }
370
371 pub async fn flush(&self) -> Result<()> {
373 if self.inner.shutdown.load(Ordering::Acquire) {
374 return Ok(());
375 }
376 let client = Arc::clone(&self.inner.client);
377 let timeout = self.inner.shutdown_timeout;
378 let flushed = tokio::task::spawn_blocking(move || client.flush(Some(timeout)))
379 .await
380 .map_err(|error| SentryError::TaskJoin(error.to_string()))?;
381 if flushed {
382 Ok(())
383 } else {
384 Err(SentryError::FlushTimeout)
385 }
386 }
387
388 pub async fn shutdown(&self) -> Result<()> {
390 if self
391 .inner
392 .shutdown
393 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
394 .is_err()
395 {
396 return Ok(());
397 }
398
399 let guard = lock(&self.inner.guard).take();
400 let hub = Arc::clone(&self.inner.hub);
401 let previous_client = self.inner.previous_client.clone();
402 let client = Arc::clone(&self.inner.client);
403 let timeout = self.inner.shutdown_timeout;
404 let flushed = tokio::task::spawn_blocking(move || {
405 let flushed = client.flush(Some(timeout));
406 hub.bind_client(previous_client);
407 drop(guard);
408 flushed
409 })
410 .await
411 .map_err(|error| SentryError::TaskJoin(error.to_string()))?;
412 if flushed {
413 Ok(())
414 } else {
415 Err(SentryError::FlushTimeout)
416 }
417 }
418}
419
420impl fmt::Debug for SentryIntegration {
421 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
422 formatter
423 .debug_struct("SentryIntegration")
424 .field("enabled", &self.inner.client.is_enabled())
425 .field("ready", &self.is_ready())
426 .field("shutdown_timeout", &self.inner.shutdown_timeout)
427 .finish_non_exhaustive()
428 }
429}
430
431#[async_trait]
432impl LifecycleHook for SentryIntegration {
433 async fn on_shutdown(&self) -> nidus_core::Result<()> {
434 self.shutdown()
435 .await
436 .map_err(|_| NidusError::ApplicationBuild {
437 message: "Sentry flush failed during shutdown".to_owned(),
438 })
439 }
440}
441
442#[derive(Clone)]
444pub struct IsolatedHubProvider {
445 base: Arc<Hub>,
446}
447
448impl<B> HubProvider<Arc<Hub>, Request<B>> for IsolatedHubProvider {
449 fn hub(&self, _request: &Request<B>) -> Arc<Hub> {
450 Arc::new(Hub::new_from_top(&self.base))
451 }
452}
453
454pub struct SentryTowerLayer<B> {
456 provider: IsolatedHubProvider,
457 http: SentryHttpLayer,
458 body: PhantomData<fn() -> B>,
459}
460
461impl<B> Clone for SentryTowerLayer<B> {
462 fn clone(&self) -> Self {
463 Self {
464 provider: self.provider.clone(),
465 http: self.http.clone(),
466 body: PhantomData,
467 }
468 }
469}
470
471impl<B> SentryTowerLayer<B> {
472 pub fn new(base: Arc<Hub>) -> Self {
474 Self {
475 provider: IsolatedHubProvider { base },
476 http: SentryHttpLayer::new().enable_transaction(),
477 body: PhantomData,
478 }
479 }
480}
481
482impl<S, B> Layer<S> for SentryTowerLayer<B> {
483 type Service = sentry_tower::SentryService<
484 sentry_tower::SentryHttpService<S>,
485 IsolatedHubProvider,
486 Arc<Hub>,
487 Request<B>,
488 >;
489
490 fn layer(&self, service: S) -> Self::Service {
491 let http = self.http.layer(service);
492 SentryLayer::new(self.provider.clone()).layer(http)
493 }
494}
495
496pub fn redact_event(mut event: Event<'static>) -> Event<'static> {
502 event.user = None;
503 event.server_name = None;
504 event.extra.clear();
505 event.tags.retain(|key, _| !is_sensitive_key(key));
506 if let Some(request) = &mut event.request {
507 request.data = None;
508 request.query_string = None;
509 request.cookies = None;
510 request.env.clear();
511 request.headers.retain(|name, _| safe_header(name));
512 if let Some(url) = &mut request.url {
513 url.set_query(None);
514 url.set_fragment(None);
515 let _ = url.set_username("");
516 let _ = url.set_password(None);
517 }
518 }
519 for breadcrumb in event.breadcrumbs.iter_mut() {
520 redact_breadcrumb(breadcrumb);
521 }
522 event
523}
524
525fn redact_breadcrumb(breadcrumb: &mut Breadcrumb) {
526 breadcrumb.data.clear();
527}
528
529struct EventDeduplicator {
530 window: Duration,
531 capacity: usize,
532 state: Mutex<HashMap<u64, Instant>>,
533}
534
535impl EventDeduplicator {
536 fn new(window: Duration, capacity: usize) -> Self {
537 Self {
538 window,
539 capacity,
540 state: Mutex::new(HashMap::new()),
541 }
542 }
543
544 fn is_duplicate(&self, event: &Event<'_>) -> bool {
545 let now = Instant::now();
546 let fingerprint = event_fingerprint(event);
547 let mut state = lock(&self.state);
548 state.retain(|_, seen_at| now.saturating_duration_since(*seen_at) <= self.window);
549 if state.contains_key(&fingerprint) {
550 return true;
551 }
552 if state.len() >= self.capacity
553 && let Some(oldest) = state
554 .iter()
555 .min_by_key(|(_, seen_at)| **seen_at)
556 .map(|(key, _)| *key)
557 {
558 state.remove(&oldest);
559 }
560 state.insert(fingerprint, now);
561 false
562 }
563}
564
565fn event_fingerprint(event: &Event<'_>) -> u64 {
566 let mut hasher = DefaultHasher::new();
567 event.level.hash(&mut hasher);
568 event.message.hash(&mut hasher);
569 event.transaction.hash(&mut hasher);
570 event.logger.hash(&mut hasher);
571 for exception in event.exception.iter() {
572 exception.ty.hash(&mut hasher);
573 exception.value.hash(&mut hasher);
574 exception.module.hash(&mut hasher);
575 }
576 for fingerprint in event.fingerprint.iter() {
577 fingerprint.hash(&mut hasher);
578 }
579 hasher.finish()
580}
581
582fn safe_header(name: &str) -> bool {
583 matches!(
584 name.to_ascii_lowercase().as_str(),
585 "accept"
586 | "content-length"
587 | "content-type"
588 | "host"
589 | "traceparent"
590 | "tracestate"
591 | "sentry-trace"
592 | "x-request-id"
593 )
594}
595
596fn is_sensitive_key(key: &str) -> bool {
597 let key = key.to_ascii_lowercase();
598 [
599 "authorization",
600 "cookie",
601 "password",
602 "secret",
603 "token",
604 "api_key",
605 "apikey",
606 "session",
607 "email",
608 "username",
609 "ip_address",
610 ]
611 .iter()
612 .any(|sensitive| key.contains(sensitive))
613}
614
615fn parse_dsn(value: &str) -> Result<sentry::types::Dsn> {
616 value
617 .parse()
618 .map_err(|_| SentryError::Configuration("Sentry DSN could not be parsed".to_owned()))
619}
620
621fn is_loopback_host(host: &str) -> bool {
622 matches!(host, "localhost" | "127.0.0.1" | "::1")
623}
624
625fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
626 mutex
627 .lock()
628 .unwrap_or_else(|poisoned| poisoned.into_inner())
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn before_send_redacts_and_deduplicates_events() {
637 let options = SentryConfig::new("https://public@sentry.invalid/1")
638 .client_options()
639 .unwrap();
640 let events = sentry::test::with_captured_events_options(
641 || {
642 for _ in 0..2 {
643 let mut request = sentry::protocol::Request {
644 url: Some(
645 "https://alice:password@example.test/orders?token=secret"
646 .parse()
647 .unwrap(),
648 ),
649 query_string: Some("token=secret".to_owned()),
650 cookies: Some("session=secret".to_owned()),
651 data: Some("credit_card=secret".to_owned()),
652 ..Default::default()
653 };
654 request
655 .headers
656 .insert("authorization".to_owned(), "Bearer secret".to_owned());
657 request
658 .headers
659 .insert("content-type".to_owned(), "application/json".to_owned());
660 request
661 .headers
662 .insert("baggage".to_owned(), "tenant.email=alice".to_owned());
663 let mut event = Event {
664 message: Some("database unavailable".to_owned()),
665 request: Some(request),
666 user: Some(sentry::protocol::User {
667 email: Some("alice@example.test".to_owned()),
668 ..Default::default()
669 }),
670 ..Default::default()
671 };
672 event
673 .tags
674 .insert("api_token".to_owned(), "secret".to_owned());
675 event.extra.insert(
676 "payload".to_owned(),
677 sentry::protocol::Value::String("secret".to_owned()),
678 );
679 sentry::capture_event(event);
680 }
681 },
682 options,
683 );
684
685 assert_eq!(events.len(), 1);
686 let event = &events[0];
687 assert!(event.user.is_none());
688 assert!(event.extra.is_empty());
689 assert!(!event.tags.contains_key("api_token"));
690 let request = event.request.as_ref().unwrap();
691 assert!(request.data.is_none());
692 assert!(request.query_string.is_none());
693 assert!(request.cookies.is_none());
694 assert!(!request.headers.contains_key("authorization"));
695 assert!(!request.headers.contains_key("baggage"));
696 assert_eq!(
697 request.headers.get("content-type").map(String::as_str),
698 Some("application/json")
699 );
700 let url = request.url.as_ref().unwrap();
701 assert!(url.query().is_none());
702 assert!(url.username().is_empty());
703 assert!(url.password().is_none());
704 }
705}