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
6#[cfg(feature = "posthog")]
7pub mod posthog;
8
9#[cfg(feature = "sentry")]
10pub mod sentry;
11
12/// Keeps the telemetry client alive until shutdown so buffered events can flush.
13pub struct TelemetryGuard {
14    #[cfg(feature = "sentry")]
15    inner: Option<::sentry::ClientInitGuard>,
16}
17
18#[cfg(feature = "sentry")]
19impl Drop for TelemetryGuard {
20    #[inline]
21    fn drop(&mut self) {
22        drop(self.inner.take());
23    }
24}
25
26/// Initializes telemetry from the configured environment and returns its guard.
27#[must_use]
28#[inline]
29pub fn init() -> TelemetryGuard {
30    #[cfg(feature = "sentry")]
31    let inner = configured_dsn()
32        .and_then(|data_source_name| crate::telemetry::sentry::init_sentry(&data_source_name));
33    TelemetryGuard {
34        #[cfg(feature = "sentry")]
35        inner,
36    }
37}
38
39/// Returns a Sentry tracing layer when telemetry is initialized.
40#[cfg(feature = "sentry")]
41#[must_use]
42#[inline]
43pub fn tracing_layer<S>() -> Option<::sentry::integrations::tracing::SentryLayer<S>>
44where
45    S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>,
46{
47    ::sentry::Hub::current()
48        .client()
49        .map(|_| crate::telemetry::sentry::tracing_layer())
50}
51
52/// Returns a tower layer that binds a Sentry hub to each request when telemetry is initialized.
53#[cfg(feature = "sentry")]
54#[must_use]
55#[inline]
56pub fn tower_new_layer(
57) -> Option<::sentry::integrations::tower::NewSentryLayer<axum::http::Request<axum::body::Body>>> {
58    ::sentry::Hub::current()
59        .client()
60        .map(|_| crate::telemetry::sentry::tower_new_layer())
61}
62
63/// Returns a tower HTTP layer that enriches Sentry events when telemetry is initialized.
64#[cfg(feature = "sentry")]
65#[must_use]
66#[inline]
67pub fn tower_http_layer() -> Option<::sentry::integrations::tower::SentryHttpLayer> {
68    ::sentry::Hub::current()
69        .client()
70        .map(|_| crate::telemetry::sentry::tower_http_layer())
71}
72
73#[cfg(feature = "sentry")]
74fn configured_dsn() -> Option<String> {
75    for name in ["DOCSPEC_SENTRY_DSN", "SENTRY_DSN"] {
76        match std::env::var(name) {
77            Ok(value) if !value.is_empty() => return Some(value),
78            _ => {}
79        }
80    }
81    None
82}
83
84/// Swappable slot for the `PostHog` client.
85///
86/// `OnceLock` initialises the `RwLock` exactly once; subsequent reads and
87/// writes go through the `RwLock`, which allows tests to replace the client
88/// between runs. `serve()` calls [`install_posthog_client`] at startup — with
89/// the freshly built client when configured, or `None` otherwise — and test
90/// harnesses use the same helper (or the slot directly) to inject a
91/// wiremock-backed client before each scenario.
92#[cfg(feature = "posthog")]
93pub(crate) static POSTHOG_CLIENT: std::sync::OnceLock<
94    std::sync::RwLock<Option<std::sync::Arc<posthog_rs::Client>>>,
95> = std::sync::OnceLock::new();
96
97/// Returns a reference to the shared `PostHog` client slot.
98///
99/// The slot is initialised to `None` on first access. `serve()` writes a
100/// live client after init; tests write a stub before each scenario.
101#[cfg(feature = "posthog")]
102#[inline]
103pub fn posthog_client_slot(
104) -> &'static std::sync::RwLock<Option<std::sync::Arc<posthog_rs::Client>>> {
105    POSTHOG_CLIENT.get_or_init(|| std::sync::RwLock::new(None))
106}
107
108/// Constructs a `PostHog` client from environment variables and returns it
109/// wrapped in an `Arc`.
110///
111/// This function is a pure factory: it does **not** write to
112/// [`posthog_client_slot`]. Callers must invoke [`install_posthog_client`]
113/// with the result — including passing `None` when the factory returns `None`
114/// — so that any stale client from a prior run in the same process is
115/// cleared rather than left behind.
116///
117/// Returns `None` when the API key env var is absent, or the SDK refuses to
118/// initialise.
119#[cfg(feature = "posthog")]
120#[inline]
121pub async fn init_posthog_client_from_env() -> Option<std::sync::Arc<posthog_rs::Client>> {
122    let config = posthog::configured_posthog_config()?;
123    let client = posthog::init_posthog(config).await?;
124    Some(std::sync::Arc::new(client))
125}
126
127/// Installs `client` into the shared `PostHog` slot, replacing any previous
128/// value.
129///
130/// Pass `None` to clear the slot. `serve()` always calls this — with either
131/// the freshly built client, or `None` when no API key is configured on the
132/// current run — so that a stale or already-shutdown client from a previous
133/// server startup in the same process cannot linger into a subsequent run
134/// and silently receive analytics against outdated configuration.
135///
136/// If the slot's `RwLock` is poisoned, the install is skipped and a warning
137/// is logged; subsequent captures will find no client, disabling `PostHog`
138/// for the rest of the process. This is safer than leaving a stale value in
139/// place: no analytics is preferable to analytics sent to the wrong
140/// destination.
141#[cfg(feature = "posthog")]
142#[inline]
143pub fn install_posthog_client(client: Option<std::sync::Arc<posthog_rs::Client>>) {
144    let has_client = client.is_some();
145    match posthog_client_slot().write() {
146        Ok(mut slot) => {
147            *slot = client;
148            if has_client {
149                tracing::info!("posthog client initialized");
150            } else {
151                tracing::debug!("posthog client not configured; slot cleared");
152            }
153        }
154        Err(_) => {
155            tracing::warn!("posthog client slot poisoned during install; disabling posthog");
156        }
157    }
158}
159
160/// Flushes the `PostHog` client, waiting for in-flight events to drain.
161///
162/// Reads the client slot, clones the `Arc` if present (dropping the read
163/// guard before awaiting to avoid holding it across an await point), then
164/// calls `client.shutdown()`.
165#[cfg(feature = "posthog")]
166#[inline]
167pub async fn shutdown() {
168    let client_arc = match posthog_client_slot().read() {
169        Ok(guard) => guard.as_ref().map(std::sync::Arc::clone),
170        Err(_poisoned) => {
171            tracing::warn!("posthog client slot poisoned during shutdown; skipping flush");
172            None
173        }
174    };
175    if let Some(client) = client_arc {
176        client.shutdown().await;
177    }
178}
179
180/// No-op shutdown when the `posthog` feature is disabled.
181///
182/// Returns a `Ready<()>` future so call sites can uniformly `.await` it.
183#[cfg(not(feature = "posthog"))]
184#[inline]
185pub fn shutdown() -> core::future::Ready<()> {
186    core::future::ready(())
187}
188
189/// Parses a float rate from the named environment variable, returning the
190/// default if the variable is absent, empty, unparseable, or not finite, and
191/// clamping the result to `[0.0, 1.0]`.
192///
193/// Shared by the Sentry and `PostHog` backends.
194#[cfg(any(feature = "sentry", feature = "posthog"))]
195pub(in crate::telemetry) fn env_rate(name: &str, default: f32) -> f32 {
196    let Ok(value) = std::env::var(name) else {
197        return default;
198    };
199
200    match value.parse::<f32>() {
201        Ok(rate) if rate.is_finite() => rate.clamp(0.0, 1.0),
202        _ => {
203            eprintln!("warning: {name} invalid or out-of-range; clamped to default {default:.1}");
204            default
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    #![allow(
212        clippy::expect_used,
213        clippy::unwrap_used,
214        clippy::semicolon_outside_block
215    )]
216    #[cfg(feature = "sentry")]
217    use std::sync::Mutex;
218
219    #[cfg(feature = "sentry")]
220    static ENV_MUTEX: Mutex<()> = Mutex::new(());
221
222    #[cfg(feature = "sentry")]
223    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
224        match ENV_MUTEX.lock() {
225            Ok(guard) => guard,
226            Err(poisoned) => poisoned.into_inner(),
227        }
228    }
229
230    #[test]
231    fn no_sentry_guard_drops_without_panic() {
232        let _guard = crate::telemetry::init();
233    }
234
235    #[cfg(feature = "posthog")]
236    #[test]
237    fn posthog_slot_starts_empty() {
238        let slot = crate::telemetry::posthog_client_slot();
239        let guard = slot.read().expect("slot read should not be poisoned");
240        assert!(guard.is_none(), "slot should be None before init");
241    }
242
243    #[cfg(feature = "posthog")]
244    #[test]
245    fn posthog_slot_write_then_read_returns_some() {
246        let slot = crate::telemetry::posthog_client_slot();
247
248        // Simulate what serve() does at startup — write a fabricated Arc.
249        // We cannot construct a real Client without a live network, so use
250        // a test-only approach: write None, then write Some to prove round-trip.
251        {
252            let mut guard = slot.write().expect("slot write should not be poisoned");
253            *guard = None;
254        }
255        {
256            let guard = slot.read().expect("slot read should not be poisoned");
257            assert!(guard.is_none());
258        }
259        // Re-write to None proves teardown works (mirrors test cleanup pattern).
260        {
261            let mut guard = slot.write().expect("slot write should not be poisoned");
262            let _ = guard.take();
263        }
264    }
265
266    #[cfg(feature = "posthog")]
267    #[tokio::test]
268    async fn posthog_shutdown_on_empty_slot_is_noop() {
269        crate::telemetry::shutdown().await;
270    }
271
272    #[cfg(feature = "sentry")]
273    #[test]
274    fn telemetry_init_returns_noop_guard_when_dsn_absent() {
275        let _env_guard = lock_env();
276        std::env::remove_var("DOCSPEC_SENTRY_DSN");
277        std::env::remove_var("SENTRY_DSN");
278
279        let _telemetry = crate::telemetry::init();
280
281        assert!(crate::telemetry::tracing_layer::<tracing_subscriber::Registry>().is_none());
282        assert!(crate::telemetry::tower_new_layer().is_none());
283    }
284
285    #[cfg(feature = "sentry")]
286    #[test]
287    fn resolve_dsn_picks_docspec_over_sentry() {
288        let _env_guard = lock_env();
289        std::env::set_var("DOCSPEC_SENTRY_DSN", "https://docspec.example/1");
290        std::env::set_var("SENTRY_DSN", "https://sentry.example/1");
291
292        assert_eq!(
293            super::configured_dsn(),
294            Some("https://docspec.example/1".to_string())
295        );
296    }
297
298    #[cfg(feature = "sentry")]
299    #[test]
300    fn resolve_dsn_treats_empty_string_as_absent() {
301        let _env_guard = lock_env();
302        std::env::set_var("DOCSPEC_SENTRY_DSN", "");
303        std::env::remove_var("SENTRY_DSN");
304
305        assert_eq!(super::configured_dsn(), None);
306    }
307}