docspec_http/telemetry/
posthog.rs1pub(in crate::telemetry) struct PostHogConfig {
8 api_key: String,
10 host: String,
12}
13
14pub(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
40pub(crate) fn env_sample_rate() -> f32 {
43 super::env_rate("POSTHOG_SAMPLE_RATE", 1.0)
44}
45
46pub(crate) const RELEASE: &str = concat!("docspec-http@", env!("CARGO_PKG_VERSION"));
48
49pub(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
77fn 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}