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