Skip to main content

docspec_http/telemetry/
posthog.rs

1//! `PostHog` telemetry backend.
2//!
3//! Activation requires the `posthog` Cargo feature. Set `DOCSPEC_POSTHOG_API_KEY`
4//! (or `POSTHOG_API_KEY`) in the environment. All other configuration is optional.
5
6/// `PostHog` client configuration resolved from environment variables.
7pub(in crate::telemetry) struct PostHogConfig {
8    /// `PostHog` project API key.
9    api_key: String,
10    /// `PostHog` ingest host URL.
11    host: String,
12}
13
14/// Resolves `PostHog` configuration from environment variables.
15///
16/// Reads `DOCSPEC_POSTHOG_API_KEY` first, then `POSTHOG_API_KEY` as fallback.
17/// Reads `DOCSPEC_POSTHOG_HOST` first, then `POSTHOG_HOST` as fallback;
18/// defaults to `https://us.i.posthog.com`.
19///
20/// Returns `None` when neither API key variable is set or non-empty.
21pub(in crate::telemetry) fn configured_posthog_config() -> Option<PostHogConfig> {
22    let api_key = ["DOCSPEC_POSTHOG_API_KEY", "POSTHOG_API_KEY"]
23        .iter()
24        .find_map(|name| match std::env::var(name) {
25            Ok(value) if !value.is_empty() => Some(value),
26            _ => None,
27        })?;
28
29    let host = ["DOCSPEC_POSTHOG_HOST", "POSTHOG_HOST"]
30        .iter()
31        .find_map(|name| match std::env::var(name) {
32            Ok(value) if !value.is_empty() => Some(value),
33            _ => None,
34        })
35        .unwrap_or_else(|| "https://us.i.posthog.com".to_owned());
36
37    Some(PostHogConfig { api_key, host })
38}
39
40/// `PostHog` event sample rate read from `POSTHOG_SAMPLE_RATE`. Default `1.0`,
41/// clamped to `[0.0, 1.0]`.
42pub(crate) fn env_sample_rate() -> f32 {
43    super::env_rate("POSTHOG_SAMPLE_RATE", 1.0)
44}
45
46/// Release identifier sent with every `PostHog` analytics event.
47pub(crate) const RELEASE: &str = concat!("docspec-http@", env!("CARGO_PKG_VERSION"));
48
49/// Initializes a `PostHog` client from the provided configuration.
50///
51/// Wires in the sample-rate disable flag and the `before_send` hook.
52/// Returns `None` when the SDK refuses to build with the given options,
53/// logging a `warn` with the underlying error so operators have a
54/// diagnostic signal instead of silent telemetry loss.
55pub(in crate::telemetry) async fn init_posthog(
56    config: PostHogConfig,
57) -> Option<posthog_rs::Client> {
58    tracing::debug!(release = %RELEASE, "initializing posthog client");
59    let options = match posthog_rs::ClientOptionsBuilder::default()
60        .api_key(config.api_key)
61        .host(config.host)
62        .disabled(env_sample_rate() <= 0.0)
63        .is_server(true)
64        .disable_geoip(false)
65        .before_send(before_send)
66        .build()
67    {
68        Ok(opts) => opts,
69        Err(error) => {
70            tracing::warn!(%error, "posthog client options build failed; disabling posthog");
71            return None;
72        }
73    };
74    Some(posthog_rs::client(options).await)
75}
76
77/// Strips any event property whose key contains "body" (case-insensitive).
78///
79/// Iterates `Event::properties()` (a `&HashMap`) to collect every key whose
80/// lowercased form contains the substring "body", then removes each via
81/// `remove_prop` — a two-pass approach because the iterator borrows the map
82/// immutably. Because the capture sites (`error.rs`, `handlers/conversion.rs`)
83/// never insert body keys, this hook is redundant in normal operation but
84/// guards against future regressions.
85fn before_send(mut event: posthog_rs::Event) -> Option<posthog_rs::Event> {
86    let keys_to_remove: Vec<String> = event
87        .properties()
88        .keys()
89        .filter(|key| key.to_ascii_lowercase().contains("body"))
90        .cloned()
91        .collect();
92
93    for key in keys_to_remove {
94        event.remove_prop(&key);
95    }
96    Some(event)
97}
98
99#[cfg(test)]
100mod tests {
101    #![allow(clippy::expect_used, clippy::unwrap_used)]
102    use std::sync::Mutex;
103
104    static ENV_MUTEX: Mutex<()> = Mutex::new(());
105
106    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
107        match ENV_MUTEX.lock() {
108            Ok(guard) => guard,
109            Err(poisoned) => poisoned.into_inner(),
110        }
111    }
112
113    #[test]
114    fn configured_posthog_config_prefers_docspec_api_key() {
115        let _env_guard = lock_env();
116        std::env::set_var("DOCSPEC_POSTHOG_API_KEY", "docspec-key");
117        std::env::set_var("POSTHOG_API_KEY", "posthog-key");
118
119        let config = super::configured_posthog_config().expect("config should be Some");
120        assert_eq!(config.api_key, "docspec-key");
121
122        std::env::remove_var("DOCSPEC_POSTHOG_API_KEY");
123        std::env::remove_var("POSTHOG_API_KEY");
124    }
125
126    #[test]
127    fn configured_posthog_config_falls_back_to_posthog_api_key() {
128        let _env_guard = lock_env();
129        std::env::remove_var("DOCSPEC_POSTHOG_API_KEY");
130        std::env::set_var("POSTHOG_API_KEY", "fallback-key");
131
132        let config = super::configured_posthog_config().expect("config should be Some");
133        assert_eq!(config.api_key, "fallback-key");
134
135        std::env::remove_var("POSTHOG_API_KEY");
136    }
137
138    #[test]
139    fn configured_posthog_config_returns_none_when_no_key() {
140        let _env_guard = lock_env();
141        std::env::remove_var("DOCSPEC_POSTHOG_API_KEY");
142        std::env::remove_var("POSTHOG_API_KEY");
143
144        assert!(super::configured_posthog_config().is_none());
145    }
146
147    #[test]
148    fn configured_posthog_config_treats_empty_api_key_as_absent() {
149        let _env_guard = lock_env();
150        std::env::set_var("DOCSPEC_POSTHOG_API_KEY", "");
151        std::env::remove_var("POSTHOG_API_KEY");
152
153        assert!(super::configured_posthog_config().is_none());
154
155        std::env::remove_var("DOCSPEC_POSTHOG_API_KEY");
156    }
157
158    #[test]
159    fn configured_posthog_config_uses_default_host_when_absent() {
160        let _env_guard = lock_env();
161        std::env::set_var("DOCSPEC_POSTHOG_API_KEY", "test-key");
162        std::env::remove_var("DOCSPEC_POSTHOG_HOST");
163        std::env::remove_var("POSTHOG_HOST");
164
165        let config = super::configured_posthog_config().expect("config should be Some");
166        assert_eq!(config.host, "https://us.i.posthog.com");
167
168        std::env::remove_var("DOCSPEC_POSTHOG_API_KEY");
169    }
170
171    #[test]
172    fn configured_posthog_config_prefers_docspec_host() {
173        let _env_guard = lock_env();
174        std::env::set_var("DOCSPEC_POSTHOG_API_KEY", "test-key");
175        std::env::set_var("DOCSPEC_POSTHOG_HOST", "https://docspec.i.posthog.com");
176        std::env::set_var("POSTHOG_HOST", "https://fallback.i.posthog.com");
177
178        let config = super::configured_posthog_config().expect("config should be Some");
179        assert_eq!(config.host, "https://docspec.i.posthog.com");
180
181        std::env::remove_var("DOCSPEC_POSTHOG_API_KEY");
182        std::env::remove_var("DOCSPEC_POSTHOG_HOST");
183        std::env::remove_var("POSTHOG_HOST");
184    }
185
186    #[test]
187    fn env_sample_rate_defaults_to_one() {
188        let _env_guard = lock_env();
189        std::env::remove_var("POSTHOG_SAMPLE_RATE");
190        assert_eq!(super::env_sample_rate().to_bits(), f32::to_bits(1.0));
191    }
192
193    #[test]
194    fn env_sample_rate_clamps_high() {
195        let _env_guard = lock_env();
196        std::env::set_var("POSTHOG_SAMPLE_RATE", "5.0");
197        assert_eq!(super::env_sample_rate().to_bits(), f32::to_bits(1.0));
198        std::env::remove_var("POSTHOG_SAMPLE_RATE");
199    }
200
201    #[test]
202    fn env_sample_rate_clamps_low() {
203        let _env_guard = lock_env();
204        std::env::set_var("POSTHOG_SAMPLE_RATE", "-0.5");
205        assert_eq!(super::env_sample_rate().to_bits(), f32::to_bits(0.0));
206        std::env::remove_var("POSTHOG_SAMPLE_RATE");
207    }
208
209    #[test]
210    fn before_send_strips_known_body_keys() {
211        let mut event = posthog_rs::Event::new("test", "user-123");
212        event
213            .insert_prop("request_body", "secret document")
214            .expect("insert should succeed");
215        event
216            .insert_prop("response_body", "secret output")
217            .expect("insert should succeed");
218        event
219            .insert_prop("result", "success")
220            .expect("insert should succeed");
221
222        let mut result = super::before_send(event).expect("hook should keep event");
223
224        assert!(
225            result.remove_prop("request_body").is_none(),
226            "request_body should have been stripped"
227        );
228        assert!(
229            result.remove_prop("response_body").is_none(),
230            "response_body should have been stripped"
231        );
232        assert!(
233            result.remove_prop("result").is_some(),
234            "non-body properties should be preserved"
235        );
236    }
237
238    #[test]
239    fn before_send_strips_body_keys_case_insensitive() {
240        let mut event = posthog_rs::Event::new("test", "user-123");
241        event
242            .insert_prop("RequestBody", "secret")
243            .expect("insert should succeed");
244        event
245            .insert_prop("BODY", "secret")
246            .expect("insert should succeed");
247        event
248            .insert_prop("response_body", "secret")
249            .expect("insert should succeed");
250        event
251            .insert_prop("MyBodyField", "secret")
252            .expect("insert should succeed");
253        event
254            .insert_prop("status", "ok")
255            .expect("insert should succeed");
256
257        let mut result = super::before_send(event).expect("hook should keep event");
258
259        assert!(
260            result.remove_prop("RequestBody").is_none(),
261            "RequestBody (mixed case) should have been stripped"
262        );
263        assert!(
264            result.remove_prop("BODY").is_none(),
265            "BODY (uppercase) should have been stripped"
266        );
267        assert!(
268            result.remove_prop("response_body").is_none(),
269            "response_body (lowercase) should have been stripped"
270        );
271        assert!(
272            result.remove_prop("MyBodyField").is_none(),
273            "MyBodyField (contains body) should have been stripped"
274        );
275        assert!(
276            result.remove_prop("status").is_some(),
277            "status (no body substring) should be preserved"
278        );
279    }
280
281    #[test]
282    fn release_constant_contains_package_version() {
283        assert!(
284            super::RELEASE.starts_with("docspec-http@"),
285            "RELEASE should start with docspec-http@"
286        );
287        assert!(
288            super::RELEASE.len() > "docspec-http@".len(),
289            "RELEASE should have a version after the @ sign"
290        );
291    }
292}