Skip to main content

posthog_rs/client/
mod.rs

1use crate::endpoints::{EndpointManager, DEFAULT_HOST};
2use derive_builder::Builder;
3use tracing::warn;
4
5#[cfg(not(feature = "async-client"))]
6mod blocking;
7#[cfg(not(feature = "async-client"))]
8pub use blocking::client;
9#[cfg(not(feature = "async-client"))]
10pub use blocking::Client;
11
12#[cfg(feature = "async-client")]
13mod async_client;
14#[cfg(feature = "async-client")]
15pub use async_client::client;
16#[cfg(feature = "async-client")]
17pub use async_client::Client;
18
19/// Configuration options for the PostHog client.
20///
21/// Use [`ClientOptionsBuilder`] to construct options with custom settings,
22/// or create directly from an API key using `ClientOptions::from("your-api-key")`.
23///
24/// # Example
25///
26/// ```ignore
27/// use posthog_rs::ClientOptionsBuilder;
28///
29/// let options = ClientOptionsBuilder::default()
30///     .api_key("your-api-key".to_string())
31///     .host("https://eu.posthog.com")
32///     .build()
33///     .unwrap();
34/// ```
35#[derive(Builder, Clone)]
36pub struct ClientOptions {
37    /// Host URL for the PostHog API (defaults to US ingestion endpoint)
38    #[builder(setter(into, strip_option), default)]
39    host: Option<String>,
40
41    /// Project API key (required)
42    api_key: String,
43
44    /// Request timeout in seconds
45    #[builder(default = "30")]
46    request_timeout_seconds: u64,
47
48    /// Personal API key for fetching flag definitions (required for local evaluation)
49    #[builder(setter(into, strip_option), default)]
50    personal_api_key: Option<String>,
51
52    /// Enable local evaluation of feature flags
53    #[builder(default = "false")]
54    enable_local_evaluation: bool,
55
56    /// Interval for polling flag definitions (in seconds)
57    #[builder(default = "30")]
58    poll_interval_seconds: u64,
59
60    /// Disable tracking (useful for development)
61    #[builder(default = "false")]
62    disabled: bool,
63
64    /// Disable automatic geoip enrichment
65    #[builder(default = "false")]
66    disable_geoip: bool,
67
68    /// Feature flags request timeout in seconds
69    #[builder(default = "3")]
70    feature_flags_request_timeout_seconds: u64,
71
72    /// When true, never fall back to the remote API for flag evaluation. If local
73    /// evaluation is inconclusive (flag not cached or missing properties), the SDK
74    /// returns `Ok(None)` instead of making a network call. Only meaningful when
75    /// `enable_local_evaluation` is also true.
76    #[builder(default = "false")]
77    local_evaluation_only: bool,
78
79    #[builder(setter(skip))]
80    #[builder(default = "EndpointManager::new(DEFAULT_HOST.to_string())")]
81    endpoint_manager: EndpointManager,
82}
83
84impl ClientOptions {
85    /// Get the endpoint manager
86    pub(crate) fn endpoints(&self) -> &EndpointManager {
87        &self.endpoint_manager
88    }
89
90    /// Check if the client is disabled
91    pub fn is_disabled(&self) -> bool {
92        self.disabled
93    }
94
95    fn sanitize(mut self) -> Self {
96        self.api_key = self.api_key.trim().to_string();
97        if self.api_key.is_empty() {
98            warn!("api_key is empty after trimming whitespace; check your project API key");
99        }
100        self.host = Some(match self.host {
101            Some(host) => {
102                let normalized = host.trim().to_string();
103                if normalized.is_empty() {
104                    DEFAULT_HOST.to_string()
105                } else {
106                    normalized
107                }
108            }
109            None => DEFAULT_HOST.to_string(),
110        });
111        self.personal_api_key = self.personal_api_key.and_then(|personal_api_key| {
112            let normalized = personal_api_key.trim().to_string();
113            if normalized.is_empty() {
114                None
115            } else {
116                Some(normalized)
117            }
118        });
119        self.endpoint_manager = EndpointManager::new(
120            self.host
121                .clone()
122                .expect("host is always normalized in sanitize"),
123        );
124        self
125    }
126
127    /// Create ClientOptions with properly initialized endpoint_manager
128    fn with_endpoint_manager(self) -> Self {
129        self.sanitize()
130    }
131}
132
133impl From<&str> for ClientOptions {
134    fn from(api_key: &str) -> Self {
135        ClientOptionsBuilder::default()
136            .api_key(api_key.to_string())
137            .build()
138            .expect("We always set the API key, so this is infallible")
139            .with_endpoint_manager()
140    }
141}
142
143impl From<(&str, &str)> for ClientOptions {
144    /// Create options from API key and host
145    fn from((api_key, host): (&str, &str)) -> Self {
146        ClientOptionsBuilder::default()
147            .api_key(api_key.to_string())
148            .host(host.to_string())
149            .build()
150            .expect("We always set the API key, so this is infallible")
151            .with_endpoint_manager()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::ClientOptionsBuilder;
158    use crate::endpoints::{EU_INGESTION_ENDPOINT, US_INGESTION_ENDPOINT};
159
160    #[test]
161    fn trims_whitespace_sensitive_options() {
162        let options = ClientOptionsBuilder::default()
163            .api_key(" \n test-api-key\t ".to_string())
164            .host(" \nhttps://eu.posthog.com/\t ")
165            .personal_api_key(" \n\t ")
166            .build()
167            .unwrap()
168            .sanitize();
169
170        assert_eq!(options.api_key, "test-api-key");
171        assert_eq!(options.host.as_deref(), Some("https://eu.posthog.com/"));
172        assert_eq!(options.personal_api_key, None);
173        assert_eq!(options.endpoints().api_host(), EU_INGESTION_ENDPOINT);
174    }
175
176    #[test]
177    fn defaults_blank_host_after_trimming_whitespace() {
178        let options = ClientOptionsBuilder::default()
179            .api_key("test-api-key".to_string())
180            .host(" \n\t ")
181            .build()
182            .unwrap()
183            .sanitize();
184
185        assert_eq!(options.host.as_deref(), Some(US_INGESTION_ENDPOINT));
186        assert_eq!(options.endpoints().api_host(), US_INGESTION_ENDPOINT);
187    }
188}