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