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;
11mod on_error;
12
13pub(crate) use common::apply_on_error_hooks;
14pub(crate) use on_error::OnErrorHook;
15pub use on_error::{CaptureFailure, FlagsFailure, LocalEvaluationFailure, PostHogError};
16
17/// Request-body compression algorithm for the capture pipelines.
18///
19/// When set on [`ClientOptions`], capture requests are compressed and the
20/// matching `Content-Encoding` header is sent. The variant string matches the
21/// HTTP `Content-Encoding` token the server expects. The V0 pipeline supports
22/// `Gzip` only; V1 supports all variants.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum CaptureCompression {
25    Gzip,
26    Deflate,
27    Br,
28    Zstd,
29}
30
31impl CaptureCompression {
32    /// The HTTP `Content-Encoding` token for this algorithm.
33    pub(crate) fn content_encoding(self) -> &'static str {
34        match self {
35            CaptureCompression::Gzip => "gzip",
36            CaptureCompression::Deflate => "deflate",
37            CaptureCompression::Br => "br",
38            CaptureCompression::Zstd => "zstd",
39        }
40    }
41}
42
43#[cfg(not(feature = "async-client"))]
44mod blocking;
45mod retry;
46mod transport;
47#[cfg(not(feature = "capture-v1"))]
48mod v0_capture;
49#[cfg(feature = "capture-v1")]
50mod v1_capture;
51#[cfg(not(feature = "async-client"))]
52pub use blocking::client;
53#[cfg(not(feature = "async-client"))]
54pub use blocking::Client;
55
56#[cfg(feature = "async-client")]
57mod async_client;
58#[cfg(feature = "async-client")]
59pub use async_client::client;
60#[cfg(feature = "async-client")]
61pub use async_client::Client;
62
63type BeforeSendFn = dyn FnMut(Event) -> Option<Event> + Send + 'static;
64type SharedBeforeSendHook = Arc<Mutex<Box<BeforeSendFn>>>;
65
66/// Hook that can modify or discard events before they are sent.
67///
68/// Hooks run before serialization. Return `Some(event)` to continue sending the
69/// event, or `None` to drop it.
70///
71/// Hook panics are caught and cause the current event to be dropped. If a hook
72/// keeps mutable state, a panic can leave that state partially updated; the SDK
73/// recovers the hook mutex and subsequent events continue through the same hook.
74#[derive(Clone)]
75pub struct BeforeSendHook(SharedBeforeSendHook);
76
77impl BeforeSendHook {
78    /// Create a new before-send hook.
79    pub fn new<F>(hook: F) -> Self
80    where
81        F: FnMut(Event) -> Option<Event> + Send + 'static,
82    {
83        Self(Arc::new(Mutex::new(Box::new(hook))))
84    }
85
86    pub(crate) fn apply(&self, event: Event) -> Option<Event> {
87        let mut hook = self
88            .0
89            .lock()
90            .unwrap_or_else(|poisoned| poisoned.into_inner());
91        (hook)(event)
92    }
93}
94
95pub(crate) const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
96const SDK_USERAGENT_NAME: &str = "posthog-rs";
97
98pub(crate) fn get_default_user_agent() -> String {
99    format!("{}/{}", SDK_USERAGENT_NAME, CRATE_VERSION)
100}
101
102/// Configuration options for the PostHog client.
103///
104/// Use [`ClientOptionsBuilder`] to construct options with custom settings, or
105/// create options directly from a project API key with
106/// `ClientOptions::from("your-api-key")`.
107///
108/// # Example
109///
110/// ```ignore
111/// use posthog_rs::ClientOptionsBuilder;
112///
113/// let options = ClientOptionsBuilder::default()
114///     .api_key("your-project-api-key".to_string())
115///     .host("https://eu.posthog.com")
116///     .build()
117///     .unwrap();
118/// ```
119#[derive(Builder, Clone)]
120#[builder(build_fn(name = "build_unchecked", private))]
121pub struct ClientOptions {
122    /// Host URL for the PostHog API. Defaults to the US ingestion endpoint.
123    /// App hosts such as `https://eu.posthog.com` are normalized to ingestion
124    /// hosts before requests are sent.
125    #[builder(setter(into, strip_option), default)]
126    host: Option<String>,
127
128    /// PostHog project API key (project token). If missing or blank, the client
129    /// is disabled.
130    #[builder(default)]
131    api_key: String,
132
133    /// Request timeout in seconds for capture, batch, and local evaluation
134    /// definition requests. Defaults to `30`.
135    #[builder(default = "30")]
136    request_timeout_seconds: u64,
137
138    /// Personal API key for fetching flag definitions. Required when
139    /// `enable_local_evaluation` is `true`.
140    #[builder(setter(into, strip_option), default)]
141    personal_api_key: Option<String>,
142
143    /// Enable local evaluation of feature flags using a background definitions
144    /// poller.
145    #[builder(default = "false")]
146    enable_local_evaluation: bool,
147
148    /// Interval for polling flag definitions, in seconds. Defaults to `30`.
149    #[builder(default = "30")]
150    poll_interval_seconds: u64,
151
152    /// Disable tracking and remote flag requests. Useful for development and
153    /// tests.
154    #[builder(default = "false")]
155    disabled: bool,
156
157    /// Disable automatic GeoIP enrichment for capture and flag requests.
158    #[builder(default = "false")]
159    disable_geoip: bool,
160
161    /// Whether events originate from a server-side runtime. Defaults to `true`,
162    /// which stamps `$is_server: true` so PostHog won't attribute the host OS to
163    /// the user. Set `false` for client/CLI use (the property is then omitted).
164    #[builder(default = "true")]
165    is_server: bool,
166
167    /// Timeout in seconds for remote `/flags` requests. Defaults to `3`.
168    #[builder(default = "3")]
169    feature_flags_request_timeout_seconds: u64,
170
171    /// Maximum number of retries after a transient remote `/flags` failure
172    /// (transport error or HTTP 502/504). Defaults to `1`. Set to `0` to
173    /// disable retries.
174    #[builder(default = "1")]
175    pub(crate) feature_flags_request_max_retries: u32,
176
177    /// Error tracking stacktrace and frame classification options
178    #[cfg(feature = "error-tracking")]
179    #[builder(default)]
180    error_tracking: ErrorTrackingOptions,
181
182    /// When true, never fall back to the remote API for flag evaluation. If local
183    /// evaluation is inconclusive (flag not cached or missing properties), the SDK
184    /// returns `Ok(None)` instead of making a network call. Only meaningful when
185    /// `enable_local_evaluation` is also true.
186    #[builder(default = "false")]
187    local_evaluation_only: bool,
188
189    /// Maximum number of attempts for V1 capture requests (default: 3).
190    /// Includes the initial attempt, so `3` means 1 initial + 2 retries.
191    #[builder(default = "3")]
192    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
193    pub(crate) max_capture_attempts: u32,
194
195    /// Initial retry backoff duration in milliseconds (default: 200)
196    #[builder(default = "200")]
197    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
198    pub(crate) retry_initial_backoff_ms: u64,
199
200    /// Maximum retry backoff duration in milliseconds (default: 30000)
201    #[builder(default = "30000")]
202    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
203    pub(crate) retry_max_backoff_ms: u64,
204
205    /// Number of buffered events that triggers an automatic flush (default: 100).
206    #[builder(default = "100")]
207    pub(crate) flush_at: usize,
208
209    /// Maximum number of events sent in a single batch request (default: 100).
210    /// A flush of more than this many events is split into multiple requests.
211    #[builder(default = "100")]
212    pub(crate) max_batch_size: usize,
213
214    /// Interval between automatic time-based flushes, in milliseconds
215    /// (default: 5000).
216    #[builder(default = "5000")]
217    pub(crate) flush_interval_ms: u64,
218
219    /// Maximum number of events buffered before new events are dropped
220    /// (default: 10000). A single warning is logged while the queue is full.
221    #[builder(default = "10000")]
222    pub(crate) max_queue_size: usize,
223
224    /// Maximum time `shutdown()` and `Drop` spend draining buffered and
225    /// retrying events before abandoning the rest, in milliseconds (default:
226    /// 30000). This bounds the drain itself, including any delivery the drain
227    /// starts. It does not bound work already underway: the single background
228    /// worker performs one blocking send at a time, so an automatic flush or
229    /// drain in progress when shutdown is requested runs to completion first —
230    /// up to `request_timeout_seconds` per in-flight batch, so a large
231    /// auto-drain can delay teardown by several request timeouts. `flush()` is
232    /// unaffected.
233    #[builder(default = "30000")]
234    pub(crate) shutdown_timeout_ms: u64,
235
236    /// Optional request-body compression. When `None` (default), bodies are
237    /// sent uncompressed. The V0 pipeline supports `Gzip` only; V1 supports all
238    /// variants.
239    #[builder(default, setter(strip_option))]
240    pub(crate) capture_compression: Option<CaptureCompression>,
241
242    /// Hooks to modify, filter, or sample events before they are sent.
243    #[builder(default, setter(custom))]
244    pub(crate) before_send: Vec<BeforeSendHook>,
245
246    /// Hooks invoked once per terminal failure on a network surface (capture
247    /// batch delivery, remote `/flags` requests, the local-evaluation poller).
248    /// Observability only; registering at least one also silences the default
249    /// WARN logged for terminal capture batch rejects/exhaustion (the caller now
250    /// owns that signal).
251    #[builder(default, setter(custom))]
252    pub(crate) on_error: Vec<OnErrorHook>,
253
254    /// Extra HTTP headers injected into every outbound capture request.
255    /// Used by the SDK test harness adapter to attach `X-Test-Id` for
256    /// parallel test isolation.
257    #[cfg(feature = "test-harness")]
258    #[builder(default, setter(strip_option))]
259    #[allow(dead_code)]
260    pub(crate) extra_capture_headers: Option<std::collections::HashMap<String, String>>,
261
262    #[builder(setter(skip))]
263    #[builder(default = "EndpointManager::new(DEFAULT_HOST.to_string())")]
264    endpoint_manager: EndpointManager,
265}
266
267/// Resolved client-level default properties for capture requests.
268///
269/// Built once from [`ClientOptions`] and threaded through all event-producing
270/// paths (V0 capture, V0 flag-called host, V1 capture) so each default is
271/// applied in exactly one place with caller-wins (`entry().or_insert`)
272/// semantics.
273#[derive(Debug, Clone, Copy)]
274pub(crate) struct CaptureDefaults {
275    pub(crate) disable_geoip: bool,
276    pub(crate) is_server: bool,
277}
278
279impl ClientOptions {
280    /// Build the resolved capture defaults for this client configuration.
281    pub(crate) fn capture_defaults(&self) -> CaptureDefaults {
282        CaptureDefaults {
283            disable_geoip: self.disable_geoip,
284            is_server: self.is_server,
285        }
286    }
287
288    /// Get the endpoint manager
289    pub(crate) fn endpoints(&self) -> &EndpointManager {
290        &self.endpoint_manager
291    }
292
293    /// Get error tracking options.
294    #[cfg(feature = "error-tracking")]
295    pub(crate) fn error_tracking(&self) -> &ErrorTrackingOptions {
296        &self.error_tracking
297    }
298
299    /// Check whether the client is disabled.
300    ///
301    /// A client is disabled when configured with `disabled(true)` or when the
302    /// project API key is missing or blank after trimming.
303    pub fn is_disabled(&self) -> bool {
304        self.disabled
305    }
306
307    fn sanitize(mut self) -> Self {
308        self.api_key = self.api_key.trim().to_string();
309        if self.api_key.is_empty() {
310            warn!("api_key is empty after trimming whitespace; disabling PostHog client");
311            self.disabled = true;
312        }
313        self.host = Some(match self.host {
314            Some(host) => {
315                let normalized = host.trim().to_string();
316                if normalized.is_empty() {
317                    DEFAULT_HOST.to_string()
318                } else {
319                    normalized
320                }
321            }
322            None => DEFAULT_HOST.to_string(),
323        });
324        self.personal_api_key = self.personal_api_key.and_then(|personal_api_key| {
325            let normalized = personal_api_key.trim().to_string();
326            if normalized.is_empty() {
327                None
328            } else {
329                Some(normalized)
330            }
331        });
332        self.endpoint_manager = EndpointManager::new(
333            self.host
334                .clone()
335                .expect("host is always normalized in sanitize"),
336        );
337        self
338    }
339}
340
341impl ClientOptionsBuilder {
342    /// Add a hook that can modify or discard events before they are sent.
343    ///
344    /// Hooks should avoid panicking. Panics are caught and drop the current event,
345    /// but any mutable state captured by the hook may be left partially updated
346    /// and will be reused on subsequent calls.
347    pub fn before_send<F>(&mut self, hook: F) -> &mut Self
348    where
349        F: FnMut(Event) -> Option<Event> + Send + 'static,
350    {
351        self.before_send
352            .get_or_insert_with(Vec::new)
353            .push(BeforeSendHook::new(hook));
354        self
355    }
356
357    /// Add a hook invoked once per terminal failure on an SDK network surface.
358    ///
359    /// The hook receives a [`PostHogError`] for a capture batch the SDK gave up
360    /// delivering, a failed remote `/flags` request, or a failed
361    /// local-evaluation poll. Multiple hooks fire in registration order.
362    ///
363    /// # Observability only — never emit from the hook
364    ///
365    /// The hook MUST NOT call back into the SDK (`capture`/`capture_batch`/
366    /// `capture_exception`, `flush`, or `shutdown`): emitting an event while
367    /// handling a capture failure forms an amplification loop. The hook is
368    /// `Fn + Send + Sync` and invoked without holding any SDK lock, so it may
369    /// run concurrently on multiple threads and must be internally thread-safe.
370    /// Keep it cheap and non-blocking; the capture hook runs on the background
371    /// transport thread. Panics are caught and ignored.
372    ///
373    /// Registering a hook silences the default WARN for terminal capture
374    /// reject/exhaustion and serialization failures (the caller now owns that
375    /// signal). Shutdown-timeout, queue-full, and `before_send` drops keep their
376    /// WARN logs and do **not** fire the hook — they are not delivery failures.
377    /// The existing `/flags` and poller WARN logs are unaffected.
378    pub fn on_error<F>(&mut self, hook: F) -> &mut Self
379    where
380        F: Fn(&PostHogError<'_>) + Send + Sync + 'static,
381    {
382        self.on_error
383            .get_or_insert_with(Vec::new)
384            .push(OnErrorHook::new(hook));
385        self
386    }
387
388    /// Build sanitized [`ClientOptions`].
389    ///
390    /// Missing or whitespace-only API keys are allowed and disable the client so
391    /// SDK initialization remains infallible while avoiding requests with an
392    /// empty API key.
393    ///
394    /// # Errors
395    ///
396    /// Returns [`ClientOptionsBuilderError`] if a required builder value is
397    /// invalid according to the generated builder.
398    pub fn build(&self) -> Result<ClientOptions, ClientOptionsBuilderError> {
399        Ok(self.build_unchecked()?.sanitize())
400    }
401}
402
403impl From<&str> for ClientOptions {
404    /// Create options from a PostHog project API key.
405    fn from(api_key: &str) -> Self {
406        ClientOptionsBuilder::default()
407            .api_key(api_key.to_string())
408            .build()
409            .expect("We always set the API key, so this is infallible")
410    }
411}
412
413impl From<(&str, &str)> for ClientOptions {
414    /// Create options from a PostHog project API key and host URL.
415    fn from((api_key, host): (&str, &str)) -> Self {
416        ClientOptionsBuilder::default()
417            .api_key(api_key.to_string())
418            .host(host.to_string())
419            .build()
420            .expect("We always set the API key, so this is infallible")
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::ClientOptionsBuilder;
427    use crate::endpoints::{EU_INGESTION_ENDPOINT, US_INGESTION_ENDPOINT};
428
429    #[test]
430    fn trims_whitespace_sensitive_options() {
431        let options = ClientOptionsBuilder::default()
432            .api_key(" \n test-api-key\t ".to_string())
433            .host(" \nhttps://eu.posthog.com/\t ")
434            .personal_api_key(" \n\t ")
435            .build()
436            .unwrap();
437
438        assert_eq!(options.api_key, "test-api-key");
439        assert_eq!(options.host.as_deref(), Some("https://eu.posthog.com/"));
440        assert_eq!(options.personal_api_key, None);
441        assert_eq!(options.endpoints().api_host(), EU_INGESTION_ENDPOINT);
442    }
443
444    #[test]
445    fn defaults_blank_host_after_trimming_whitespace() {
446        let options = ClientOptionsBuilder::default()
447            .api_key("test-api-key".to_string())
448            .host(" \n\t ")
449            .build()
450            .unwrap();
451
452        assert_eq!(options.host.as_deref(), Some(US_INGESTION_ENDPOINT));
453        assert_eq!(options.endpoints().api_host(), US_INGESTION_ENDPOINT);
454    }
455
456    #[test]
457    fn builder_allows_missing_api_key_and_disables_client() {
458        let options = ClientOptionsBuilder::default().build().unwrap();
459
460        assert_eq!(options.api_key, "");
461        assert!(options.is_disabled());
462    }
463
464    #[test]
465    fn builder_disables_client_for_trim_empty_api_key() {
466        let options = ClientOptionsBuilder::default()
467            .api_key(" \n\t ".to_string())
468            .build()
469            .unwrap();
470
471        assert_eq!(options.api_key, "");
472        assert!(options.is_disabled());
473    }
474}