1use std::sync::{Arc, Mutex};
2
3use crate::endpoints::{EndpointManager, DEFAULT_HOST};
4#[cfg(feature = "error-tracking")]
5use crate::error_tracking::ErrorTrackingOptions;
6use crate::event::Event;
7use derive_builder::Builder;
8use tracing::warn;
9
10mod common;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CaptureCompression {
20 Gzip,
21 Deflate,
22 Br,
23 Zstd,
24}
25
26impl CaptureCompression {
27 pub(crate) fn content_encoding(self) -> &'static str {
29 match self {
30 CaptureCompression::Gzip => "gzip",
31 CaptureCompression::Deflate => "deflate",
32 CaptureCompression::Br => "br",
33 CaptureCompression::Zstd => "zstd",
34 }
35 }
36}
37
38#[cfg(not(feature = "async-client"))]
39mod blocking;
40mod retry;
41#[cfg(not(feature = "capture-v1"))]
42mod v0_capture;
43#[cfg(feature = "capture-v1")]
44mod v1_capture;
45#[cfg(not(feature = "async-client"))]
46pub use blocking::client;
47#[cfg(not(feature = "async-client"))]
48pub use blocking::Client;
49
50#[cfg(feature = "async-client")]
51mod async_client;
52#[cfg(feature = "async-client")]
53pub use async_client::client;
54#[cfg(feature = "async-client")]
55pub use async_client::Client;
56
57type BeforeSendFn = dyn FnMut(Event) -> Option<Event> + Send + 'static;
58type SharedBeforeSendHook = Arc<Mutex<Box<BeforeSendFn>>>;
59
60#[derive(Clone)]
69pub struct BeforeSendHook(SharedBeforeSendHook);
70
71impl BeforeSendHook {
72 pub fn new<F>(hook: F) -> Self
74 where
75 F: FnMut(Event) -> Option<Event> + Send + 'static,
76 {
77 Self(Arc::new(Mutex::new(Box::new(hook))))
78 }
79
80 pub(crate) fn apply(&self, event: Event) -> Option<Event> {
81 let mut hook = self
82 .0
83 .lock()
84 .unwrap_or_else(|poisoned| poisoned.into_inner());
85 (hook)(event)
86 }
87}
88
89pub(crate) const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
90const SDK_USERAGENT_NAME: &str = "posthog-rs";
91
92pub(crate) fn get_default_user_agent() -> String {
93 format!("{}/{}", SDK_USERAGENT_NAME, CRATE_VERSION)
94}
95
96#[derive(Builder, Clone)]
114#[builder(build_fn(name = "build_unchecked", private))]
115pub struct ClientOptions {
116 #[builder(setter(into, strip_option), default)]
120 host: Option<String>,
121
122 #[builder(default)]
125 api_key: String,
126
127 #[builder(default = "30")]
130 request_timeout_seconds: u64,
131
132 #[builder(setter(into, strip_option), default)]
135 personal_api_key: Option<String>,
136
137 #[builder(default = "false")]
140 enable_local_evaluation: bool,
141
142 #[builder(default = "30")]
144 poll_interval_seconds: u64,
145
146 #[builder(default = "false")]
149 disabled: bool,
150
151 #[builder(default = "false")]
153 disable_geoip: bool,
154
155 #[builder(default = "true")]
159 is_server: bool,
160
161 #[builder(default = "3")]
163 feature_flags_request_timeout_seconds: u64,
164
165 #[cfg(feature = "error-tracking")]
167 #[builder(default)]
168 error_tracking: ErrorTrackingOptions,
169
170 #[builder(default = "false")]
175 local_evaluation_only: bool,
176
177 #[builder(default = "3")]
180 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
181 pub(crate) max_capture_attempts: u32,
182
183 #[builder(default = "200")]
185 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
186 pub(crate) retry_initial_backoff_ms: u64,
187
188 #[builder(default = "30000")]
190 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
191 pub(crate) retry_max_backoff_ms: u64,
192
193 #[builder(default, setter(strip_option))]
197 pub(crate) capture_compression: Option<CaptureCompression>,
198
199 #[builder(default, setter(custom))]
201 pub(crate) before_send: Vec<BeforeSendHook>,
202
203 #[cfg(feature = "test-harness")]
207 #[builder(default, setter(strip_option))]
208 #[allow(dead_code)]
209 pub(crate) extra_capture_headers: Option<std::collections::HashMap<String, String>>,
210
211 #[builder(setter(skip))]
212 #[builder(default = "EndpointManager::new(DEFAULT_HOST.to_string())")]
213 endpoint_manager: EndpointManager,
214}
215
216#[derive(Debug, Clone, Copy)]
223pub(crate) struct CaptureDefaults {
224 pub(crate) disable_geoip: bool,
225 pub(crate) is_server: bool,
226}
227
228impl ClientOptions {
229 pub(crate) fn capture_defaults(&self) -> CaptureDefaults {
231 CaptureDefaults {
232 disable_geoip: self.disable_geoip,
233 is_server: self.is_server,
234 }
235 }
236
237 pub(crate) fn endpoints(&self) -> &EndpointManager {
239 &self.endpoint_manager
240 }
241
242 #[cfg(feature = "error-tracking")]
244 pub(crate) fn error_tracking(&self) -> &ErrorTrackingOptions {
245 &self.error_tracking
246 }
247
248 pub fn is_disabled(&self) -> bool {
253 self.disabled
254 }
255
256 fn sanitize(mut self) -> Self {
257 self.api_key = self.api_key.trim().to_string();
258 if self.api_key.is_empty() {
259 warn!("api_key is empty after trimming whitespace; disabling PostHog client");
260 self.disabled = true;
261 }
262 self.host = Some(match self.host {
263 Some(host) => {
264 let normalized = host.trim().to_string();
265 if normalized.is_empty() {
266 DEFAULT_HOST.to_string()
267 } else {
268 normalized
269 }
270 }
271 None => DEFAULT_HOST.to_string(),
272 });
273 self.personal_api_key = self.personal_api_key.and_then(|personal_api_key| {
274 let normalized = personal_api_key.trim().to_string();
275 if normalized.is_empty() {
276 None
277 } else {
278 Some(normalized)
279 }
280 });
281 self.endpoint_manager = EndpointManager::new(
282 self.host
283 .clone()
284 .expect("host is always normalized in sanitize"),
285 );
286 self
287 }
288}
289
290impl ClientOptionsBuilder {
291 pub fn before_send<F>(&mut self, hook: F) -> &mut Self
297 where
298 F: FnMut(Event) -> Option<Event> + Send + 'static,
299 {
300 self.before_send
301 .get_or_insert_with(Vec::new)
302 .push(BeforeSendHook::new(hook));
303 self
304 }
305
306 pub fn build(&self) -> Result<ClientOptions, ClientOptionsBuilderError> {
317 Ok(self.build_unchecked()?.sanitize())
318 }
319}
320
321impl From<&str> for ClientOptions {
322 fn from(api_key: &str) -> Self {
324 ClientOptionsBuilder::default()
325 .api_key(api_key.to_string())
326 .build()
327 .expect("We always set the API key, so this is infallible")
328 }
329}
330
331impl From<(&str, &str)> for ClientOptions {
332 fn from((api_key, host): (&str, &str)) -> Self {
334 ClientOptionsBuilder::default()
335 .api_key(api_key.to_string())
336 .host(host.to_string())
337 .build()
338 .expect("We always set the API key, so this is infallible")
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::ClientOptionsBuilder;
345 use crate::endpoints::{EU_INGESTION_ENDPOINT, US_INGESTION_ENDPOINT};
346
347 #[test]
348 fn trims_whitespace_sensitive_options() {
349 let options = ClientOptionsBuilder::default()
350 .api_key(" \n test-api-key\t ".to_string())
351 .host(" \nhttps://eu.posthog.com/\t ")
352 .personal_api_key(" \n\t ")
353 .build()
354 .unwrap();
355
356 assert_eq!(options.api_key, "test-api-key");
357 assert_eq!(options.host.as_deref(), Some("https://eu.posthog.com/"));
358 assert_eq!(options.personal_api_key, None);
359 assert_eq!(options.endpoints().api_host(), EU_INGESTION_ENDPOINT);
360 }
361
362 #[test]
363 fn defaults_blank_host_after_trimming_whitespace() {
364 let options = ClientOptionsBuilder::default()
365 .api_key("test-api-key".to_string())
366 .host(" \n\t ")
367 .build()
368 .unwrap();
369
370 assert_eq!(options.host.as_deref(), Some(US_INGESTION_ENDPOINT));
371 assert_eq!(options.endpoints().api_host(), US_INGESTION_ENDPOINT);
372 }
373
374 #[test]
375 fn builder_allows_missing_api_key_and_disables_client() {
376 let options = ClientOptionsBuilder::default().build().unwrap();
377
378 assert_eq!(options.api_key, "");
379 assert!(options.is_disabled());
380 }
381
382 #[test]
383 fn builder_disables_client_for_trim_empty_api_key() {
384 let options = ClientOptionsBuilder::default()
385 .api_key(" \n\t ".to_string())
386 .build()
387 .unwrap();
388
389 assert_eq!(options.api_key, "");
390 assert!(options.is_disabled());
391 }
392}