Skip to main content

docspec_http/telemetry/
mod.rs

1//! Telemetry facade. Currently wraps Sentry. Designed for extraction to
2//! `docspec-telemetry` when (a) OpenTelemetry is added, OR (b) `docspec-cli`
3//! wants Sentry, OR (c) Prometheus metrics land. Keep the public API surface
4//! stable and free of HTTP-specific types.
5
6pub mod sentry;
7
8/// Keeps the telemetry client alive until shutdown so buffered events can flush.
9pub struct TelemetryGuard {
10    inner: Option<::sentry::ClientInitGuard>,
11}
12
13impl Drop for TelemetryGuard {
14    #[inline]
15    fn drop(&mut self) {
16        drop(self.inner.take());
17    }
18}
19
20/// Initializes telemetry from the configured environment and returns its guard.
21#[must_use]
22#[inline]
23pub fn init() -> TelemetryGuard {
24    let guard = configured_dsn()
25        .and_then(|data_source_name| crate::telemetry::sentry::init_sentry(&data_source_name));
26    TelemetryGuard { inner: guard }
27}
28
29/// Returns a Sentry tracing layer when telemetry is initialized.
30#[must_use]
31#[inline]
32pub fn tracing_layer<S>() -> Option<::sentry::integrations::tracing::SentryLayer<S>>
33where
34    S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>,
35{
36    ::sentry::Hub::current()
37        .client()
38        .map(|_| crate::telemetry::sentry::tracing_layer())
39}
40
41/// Returns a tower layer that binds a Sentry hub to each request when telemetry is initialized.
42#[must_use]
43#[inline]
44pub fn tower_new_layer(
45) -> Option<::sentry::integrations::tower::NewSentryLayer<axum::http::Request<axum::body::Body>>> {
46    ::sentry::Hub::current()
47        .client()
48        .map(|_| crate::telemetry::sentry::tower_new_layer())
49}
50
51/// Returns a tower HTTP layer that enriches Sentry events when telemetry is initialized.
52#[must_use]
53#[inline]
54pub fn tower_http_layer() -> Option<::sentry::integrations::tower::SentryHttpLayer> {
55    ::sentry::Hub::current()
56        .client()
57        .map(|_| crate::telemetry::sentry::tower_http_layer())
58}
59
60fn configured_dsn() -> Option<String> {
61    for name in ["DOCSPEC_SENTRY_DSN", "SENTRY_DSN"] {
62        match std::env::var(name) {
63            Ok(value) if !value.is_empty() => return Some(value),
64            _ => {}
65        }
66    }
67    None
68}
69
70#[cfg(test)]
71mod tests {
72    use std::sync::Mutex;
73
74    static ENV_MUTEX: Mutex<()> = Mutex::new(());
75
76    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
77        match ENV_MUTEX.lock() {
78            Ok(guard) => guard,
79            Err(poisoned) => poisoned.into_inner(),
80        }
81    }
82
83    #[test]
84    fn telemetry_init_returns_noop_guard_when_dsn_absent() {
85        let _env_guard = lock_env();
86        std::env::remove_var("DOCSPEC_SENTRY_DSN");
87        std::env::remove_var("SENTRY_DSN");
88
89        let _telemetry = crate::telemetry::init();
90
91        assert!(crate::telemetry::tracing_layer::<tracing_subscriber::Registry>().is_none());
92        assert!(crate::telemetry::tower_new_layer().is_none());
93    }
94
95    #[test]
96    fn resolve_dsn_picks_docspec_over_sentry() {
97        let _env_guard = lock_env();
98        std::env::set_var("DOCSPEC_SENTRY_DSN", "https://docspec.example/1");
99        std::env::set_var("SENTRY_DSN", "https://sentry.example/1");
100
101        assert_eq!(
102            super::configured_dsn(),
103            Some("https://docspec.example/1".to_string())
104        );
105    }
106
107    #[test]
108    fn resolve_dsn_treats_empty_string_as_absent() {
109        let _env_guard = lock_env();
110        std::env::set_var("DOCSPEC_SENTRY_DSN", "");
111        std::env::remove_var("SENTRY_DSN");
112
113        assert_eq!(super::configured_dsn(), None);
114    }
115}