Skip to main content

nidus_sentry/
lib.rs

1#![deny(missing_docs)]
2
3//! First-party Sentry integration for Nidus.
4//!
5//! This crate owns initialization, panic/error/tracing capture, request-local
6//! Tower hubs, matched-route transactions, secure event scrubbing, bounded
7//! duplicate suppression, and graceful off-runtime flushing.
8
9use 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
35/// Result type for Sentry integration operations.
36pub type Result<T> = std::result::Result<T, SentryError>;
37
38/// Error returned by Sentry configuration or lifecycle operations.
39#[derive(Debug, thiserror::Error)]
40pub enum SentryError {
41    /// Configuration is invalid or unsafe.
42    #[error("invalid Sentry configuration: {0}")]
43    Configuration(String),
44    /// A blocking flush task failed to join.
45    #[error("Sentry lifecycle task failed: {0}")]
46    TaskJoin(String),
47    /// Sentry did not drain its bounded transport queue before the deadline.
48    #[error("Sentry transport did not flush before the configured timeout")]
49    FlushTimeout,
50}
51
52/// Redaction-safe Sentry client configuration.
53#[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    /// Creates a secure configuration from an explicit DSN.
68    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    /// Loads the DSN from `SENTRY_DSN` without exposing it in diagnostics.
83    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    /// Sets the release identifier.
90    pub fn with_release(mut self, release: impl Into<String>) -> Self {
91        self.release = Some(release.into());
92        self
93    }
94
95    /// Sets the deployment environment.
96    pub fn with_environment(mut self, environment: impl Into<String>) -> Self {
97        self.environment = Some(environment.into());
98        self
99    }
100
101    /// Sets error-event and performance-trace sample rates.
102    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    /// Sets transport shutdown and flushing timeout.
118    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    /// Sets bounded in-process duplicate suppression.
129    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    /// Allows HTTP only for an explicit loopback Sentry DSN.
145    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    /// Returns the configured release.
157    pub fn release(&self) -> Option<&str> {
158        self.release.as_deref()
159    }
160
161    /// Returns the configured environment.
162    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/// Initialized Sentry client with owned global restoration and lifecycle state.
231#[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    /// Initializes the official Sentry SDK and its panic integration.
247    ///
248    /// Keep the returned value in application lifecycle state so queued events
249    /// are flushed and the previous hub client is restored on shutdown.
250    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    /// Returns the native Sentry client.
273    pub fn client(&self) -> &Arc<Client> {
274        &self.inner.client
275    }
276
277    /// Returns the native base hub used to create isolated request hubs.
278    pub fn hub(&self) -> &Arc<Hub> {
279        &self.inner.hub
280    }
281
282    /// Registers this integration as a typed singleton dependency.
283    pub fn register(&self, container: &mut Container) -> nidus_core::Result<()> {
284        container.register_singleton(self.clone())
285    }
286
287    /// Creates the correctly ordered Tower layer for request-hub isolation,
288    /// redacted request capture, distributed tracing, and matched-route names.
289    pub fn tower_layer<B>(&self) -> SentryTowerLayer<B> {
290        SentryTowerLayer::new(Arc::clone(&self.inner.hub))
291    }
292
293    /// Creates a Sentry `tracing_subscriber` layer.
294    ///
295    /// Errors become Sentry events, warning/info events become breadcrumbs, and
296    /// info-or-higher spans participate in performance traces.
297    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    /// Captures a standard Rust error through this integration's native hub.
320    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    /// Captures a redaction-safe message at a chosen severity.
330    pub fn capture_message(&self, message: &str, level: Level) -> sentry::types::Uuid {
331        self.inner.hub.capture_message(message, level)
332    }
333
334    /// Returns whether the client is enabled and has not been shut down.
335    pub fn is_ready(&self) -> bool {
336        self.inner.client.is_enabled() && !self.inner.shutdown.load(Ordering::Acquire)
337    }
338
339    /// Adds Sentry client lifecycle state as a Nidus readiness check.
340    #[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    /// Records redaction-safe client readiness in the Nidus dashboard timeline.
359    #[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    /// Flushes queued events on a blocking worker rather than the async runtime.
372    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    /// Flushes, closes the transport, and restores the previous hub client once.
389    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/// Provider that derives a fresh request hub from a fixed base hub.
443#[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
454/// Correctly ordered Tower layer for Sentry request isolation and HTTP traces.
455pub 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    /// Creates a layer derived from a native base hub.
473    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
496/// Removes request bodies, query strings, cookies, users, secrets, and common
497/// PII fields from an event before transport.
498///
499/// Free-form error messages and exception values remain useful for diagnosis;
500/// applications must never place credentials in those strings.
501pub 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}