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)]
36#[builder(build_fn(name = "build_unchecked", private))]
37pub struct ClientOptions {
38    /// Host URL for the PostHog API (defaults to US ingestion endpoint)
39    #[builder(setter(into, strip_option), default)]
40    host: Option<String>,
41
42    /// Project API key. If missing or blank, the client is disabled.
43    #[builder(default)]
44    api_key: String,
45
46    /// Request timeout in seconds
47    #[builder(default = "30")]
48    request_timeout_seconds: u64,
49
50    /// Personal API key for fetching flag definitions (required for local evaluation)
51    #[builder(setter(into, strip_option), default)]
52    personal_api_key: Option<String>,
53
54    /// Enable local evaluation of feature flags
55    #[builder(default = "false")]
56    enable_local_evaluation: bool,
57
58    /// Interval for polling flag definitions (in seconds)
59    #[builder(default = "30")]
60    poll_interval_seconds: u64,
61
62    /// Disable tracking (useful for development)
63    #[builder(default = "false")]
64    disabled: bool,
65
66    /// Disable automatic geoip enrichment
67    #[builder(default = "false")]
68    disable_geoip: bool,
69
70    /// Feature flags request timeout in seconds
71    #[builder(default = "3")]
72    feature_flags_request_timeout_seconds: u64,
73
74    /// When true, never fall back to the remote API for flag evaluation. If local
75    /// evaluation is inconclusive (flag not cached or missing properties), the SDK
76    /// returns `Ok(None)` instead of making a network call. Only meaningful when
77    /// `enable_local_evaluation` is also true.
78    #[builder(default = "false")]
79    local_evaluation_only: bool,
80
81    #[builder(setter(skip))]
82    #[builder(default = "EndpointManager::new(DEFAULT_HOST.to_string())")]
83    endpoint_manager: EndpointManager,
84}
85
86impl ClientOptions {
87    /// Get the endpoint manager
88    pub(crate) fn endpoints(&self) -> &EndpointManager {
89        &self.endpoint_manager
90    }
91
92    /// Check if the client is disabled
93    pub fn is_disabled(&self) -> bool {
94        self.disabled
95    }
96
97    fn sanitize(mut self) -> Self {
98        self.api_key = self.api_key.trim().to_string();
99        if self.api_key.is_empty() {
100            warn!("api_key is empty after trimming whitespace; disabling PostHog client");
101            self.disabled = true;
102        }
103        self.host = Some(match self.host {
104            Some(host) => {
105                let normalized = host.trim().to_string();
106                if normalized.is_empty() {
107                    DEFAULT_HOST.to_string()
108                } else {
109                    normalized
110                }
111            }
112            None => DEFAULT_HOST.to_string(),
113        });
114        self.personal_api_key = self.personal_api_key.and_then(|personal_api_key| {
115            let normalized = personal_api_key.trim().to_string();
116            if normalized.is_empty() {
117                None
118            } else {
119                Some(normalized)
120            }
121        });
122        self.endpoint_manager = EndpointManager::new(
123            self.host
124                .clone()
125                .expect("host is always normalized in sanitize"),
126        );
127        self
128    }
129}
130
131impl ClientOptionsBuilder {
132    /// Build sanitized [`ClientOptions`].
133    ///
134    /// Missing or whitespace-only API keys are allowed and disable the client so
135    /// SDK initialization remains infallible while avoiding requests with an
136    /// empty API key.
137    pub fn build(&self) -> Result<ClientOptions, ClientOptionsBuilderError> {
138        Ok(self.build_unchecked()?.sanitize())
139    }
140}
141
142impl From<&str> for ClientOptions {
143    fn from(api_key: &str) -> Self {
144        ClientOptionsBuilder::default()
145            .api_key(api_key.to_string())
146            .build()
147            .expect("We always set the API key, so this is infallible")
148    }
149}
150
151impl From<(&str, &str)> for ClientOptions {
152    /// Create options from API key and host
153    fn from((api_key, host): (&str, &str)) -> Self {
154        ClientOptionsBuilder::default()
155            .api_key(api_key.to_string())
156            .host(host.to_string())
157            .build()
158            .expect("We always set the API key, so this is infallible")
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::ClientOptionsBuilder;
165    use crate::endpoints::{EU_INGESTION_ENDPOINT, US_INGESTION_ENDPOINT};
166
167    #[test]
168    fn trims_whitespace_sensitive_options() {
169        let options = ClientOptionsBuilder::default()
170            .api_key(" \n test-api-key\t ".to_string())
171            .host(" \nhttps://eu.posthog.com/\t ")
172            .personal_api_key(" \n\t ")
173            .build()
174            .unwrap();
175
176        assert_eq!(options.api_key, "test-api-key");
177        assert_eq!(options.host.as_deref(), Some("https://eu.posthog.com/"));
178        assert_eq!(options.personal_api_key, None);
179        assert_eq!(options.endpoints().api_host(), EU_INGESTION_ENDPOINT);
180    }
181
182    #[test]
183    fn defaults_blank_host_after_trimming_whitespace() {
184        let options = ClientOptionsBuilder::default()
185            .api_key("test-api-key".to_string())
186            .host(" \n\t ")
187            .build()
188            .unwrap();
189
190        assert_eq!(options.host.as_deref(), Some(US_INGESTION_ENDPOINT));
191        assert_eq!(options.endpoints().api_host(), US_INGESTION_ENDPOINT);
192    }
193
194    #[test]
195    fn builder_allows_missing_api_key_and_disables_client() {
196        let options = ClientOptionsBuilder::default().build().unwrap();
197
198        assert_eq!(options.api_key, "");
199        assert!(options.is_disabled());
200    }
201
202    #[test]
203    fn builder_disables_client_for_trim_empty_api_key() {
204        let options = ClientOptionsBuilder::default()
205            .api_key(" \n\t ".to_string())
206            .build()
207            .unwrap();
208
209        assert_eq!(options.api_key, "");
210        assert!(options.is_disabled());
211    }
212}