docspec_http/telemetry/
mod.rs1#[cfg(feature = "posthog")]
7pub mod posthog;
8
9#[cfg(feature = "sentry")]
10pub mod sentry;
11
12pub 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[cfg(not(feature = "posthog"))]
184#[inline]
185pub fn shutdown() -> core::future::Ready<()> {
186 core::future::ready(())
187}
188
189#[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 {
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 {
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}