Skip to main content

posthog_rs/client/
mod.rs

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/// Request-body compression algorithm for the capture pipelines.
13///
14/// When set on [`ClientOptions`], capture requests are compressed and the
15/// matching `Content-Encoding` header is sent. The variant string matches the
16/// HTTP `Content-Encoding` token the server expects. The V0 pipeline supports
17/// `Gzip` only; V1 supports all variants.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CaptureCompression {
20    Gzip,
21    Deflate,
22    Br,
23    Zstd,
24}
25
26impl CaptureCompression {
27    /// The HTTP `Content-Encoding` token for this algorithm.
28    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/// Hook that can modify or discard events before they are sent.
61///
62/// Hooks run before serialization. Return `Some(event)` to continue sending the
63/// event, or `None` to drop it.
64///
65/// Hook panics are caught and cause the current event to be dropped. If a hook
66/// keeps mutable state, a panic can leave that state partially updated; the SDK
67/// recovers the hook mutex and subsequent events continue through the same hook.
68#[derive(Clone)]
69pub struct BeforeSendHook(SharedBeforeSendHook);
70
71impl BeforeSendHook {
72    /// Create a new before-send hook.
73    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/// Configuration options for the PostHog client.
97///
98/// Use [`ClientOptionsBuilder`] to construct options with custom settings, or
99/// create options directly from a project API key with
100/// `ClientOptions::from("your-api-key")`.
101///
102/// # Example
103///
104/// ```ignore
105/// use posthog_rs::ClientOptionsBuilder;
106///
107/// let options = ClientOptionsBuilder::default()
108///     .api_key("your-project-api-key".to_string())
109///     .host("https://eu.posthog.com")
110///     .build()
111///     .unwrap();
112/// ```
113#[derive(Builder, Clone)]
114#[builder(build_fn(name = "build_unchecked", private))]
115pub struct ClientOptions {
116    /// Host URL for the PostHog API. Defaults to the US ingestion endpoint.
117    /// App hosts such as `https://eu.posthog.com` are normalized to ingestion
118    /// hosts before requests are sent.
119    #[builder(setter(into, strip_option), default)]
120    host: Option<String>,
121
122    /// PostHog project API key (project token). If missing or blank, the client
123    /// is disabled.
124    #[builder(default)]
125    api_key: String,
126
127    /// Request timeout in seconds for capture, batch, and local evaluation
128    /// definition requests. Defaults to `30`.
129    #[builder(default = "30")]
130    request_timeout_seconds: u64,
131
132    /// Personal API key for fetching flag definitions. Required when
133    /// `enable_local_evaluation` is `true`.
134    #[builder(setter(into, strip_option), default)]
135    personal_api_key: Option<String>,
136
137    /// Enable local evaluation of feature flags using a background definitions
138    /// poller.
139    #[builder(default = "false")]
140    enable_local_evaluation: bool,
141
142    /// Interval for polling flag definitions, in seconds. Defaults to `30`.
143    #[builder(default = "30")]
144    poll_interval_seconds: u64,
145
146    /// Disable tracking and remote flag requests. Useful for development and
147    /// tests.
148    #[builder(default = "false")]
149    disabled: bool,
150
151    /// Disable automatic GeoIP enrichment for capture and flag requests.
152    #[builder(default = "false")]
153    disable_geoip: bool,
154
155    /// Whether events originate from a server-side runtime. Defaults to `true`,
156    /// which stamps `$is_server: true` so PostHog won't attribute the host OS to
157    /// the user. Set `false` for client/CLI use (the property is then omitted).
158    #[builder(default = "true")]
159    is_server: bool,
160
161    /// Timeout in seconds for remote `/flags` requests. Defaults to `3`.
162    #[builder(default = "3")]
163    feature_flags_request_timeout_seconds: u64,
164
165    /// Error tracking stacktrace and frame classification options
166    #[cfg(feature = "error-tracking")]
167    #[builder(default)]
168    error_tracking: ErrorTrackingOptions,
169
170    /// When true, never fall back to the remote API for flag evaluation. If local
171    /// evaluation is inconclusive (flag not cached or missing properties), the SDK
172    /// returns `Ok(None)` instead of making a network call. Only meaningful when
173    /// `enable_local_evaluation` is also true.
174    #[builder(default = "false")]
175    local_evaluation_only: bool,
176
177    /// Maximum number of attempts for V1 capture requests (default: 3).
178    /// Includes the initial attempt, so `3` means 1 initial + 2 retries.
179    #[builder(default = "3")]
180    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
181    pub(crate) max_capture_attempts: u32,
182
183    /// Initial retry backoff duration in milliseconds (default: 200)
184    #[builder(default = "200")]
185    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
186    pub(crate) retry_initial_backoff_ms: u64,
187
188    /// Maximum retry backoff duration in milliseconds (default: 30000)
189    #[builder(default = "30000")]
190    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
191    pub(crate) retry_max_backoff_ms: u64,
192
193    /// Optional request-body compression. When `None` (default), bodies are
194    /// sent uncompressed. The V0 pipeline supports `Gzip` only; V1 supports all
195    /// variants.
196    #[builder(default, setter(strip_option))]
197    pub(crate) capture_compression: Option<CaptureCompression>,
198
199    /// Hooks to modify, filter, or sample events before they are sent.
200    #[builder(default, setter(custom))]
201    pub(crate) before_send: Vec<BeforeSendHook>,
202
203    /// Extra HTTP headers injected into every outbound capture request.
204    /// Used by the SDK test harness adapter to attach `X-Test-Id` for
205    /// parallel test isolation.
206    #[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/// Resolved client-level default properties for capture requests.
217///
218/// Built once from [`ClientOptions`] and threaded through all event-producing
219/// paths (V0 capture, V0 flag-called host, V1 capture) so each default is
220/// applied in exactly one place with caller-wins (`entry().or_insert`)
221/// semantics.
222#[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    /// Build the resolved capture defaults for this client configuration.
230    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    /// Get the endpoint manager
238    pub(crate) fn endpoints(&self) -> &EndpointManager {
239        &self.endpoint_manager
240    }
241
242    /// Get error tracking options.
243    #[cfg(feature = "error-tracking")]
244    pub(crate) fn error_tracking(&self) -> &ErrorTrackingOptions {
245        &self.error_tracking
246    }
247
248    /// Check whether the client is disabled.
249    ///
250    /// A client is disabled when configured with `disabled(true)` or when the
251    /// project API key is missing or blank after trimming.
252    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    /// Add a hook that can modify or discard events before they are sent.
292    ///
293    /// Hooks should avoid panicking. Panics are caught and drop the current event,
294    /// but any mutable state captured by the hook may be left partially updated
295    /// and will be reused on subsequent calls.
296    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    /// Build sanitized [`ClientOptions`].
307    ///
308    /// Missing or whitespace-only API keys are allowed and disable the client so
309    /// SDK initialization remains infallible while avoiding requests with an
310    /// empty API key.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`ClientOptionsBuilderError`] if a required builder value is
315    /// invalid according to the generated builder.
316    pub fn build(&self) -> Result<ClientOptions, ClientOptionsBuilderError> {
317        Ok(self.build_unchecked()?.sanitize())
318    }
319}
320
321impl From<&str> for ClientOptions {
322    /// Create options from a PostHog project API key.
323    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    /// Create options from a PostHog project API key and host URL.
333    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}