use crate::endpoints::{EndpointManager, DEFAULT_HOST};
use derive_builder::Builder;
use tracing::warn;
mod common;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureCompression {
Gzip,
Deflate,
Br,
Zstd,
}
impl CaptureCompression {
pub(crate) fn content_encoding(self) -> &'static str {
match self {
CaptureCompression::Gzip => "gzip",
CaptureCompression::Deflate => "deflate",
CaptureCompression::Br => "br",
CaptureCompression::Zstd => "zstd",
}
}
}
#[cfg(not(feature = "async-client"))]
mod blocking;
mod retry;
#[cfg(not(feature = "capture-v1"))]
mod v0_capture;
#[cfg(feature = "capture-v1")]
mod v1_capture;
#[cfg(not(feature = "async-client"))]
pub use blocking::client;
#[cfg(not(feature = "async-client"))]
pub use blocking::Client;
#[cfg(feature = "async-client")]
mod async_client;
#[cfg(feature = "async-client")]
pub use async_client::client;
#[cfg(feature = "async-client")]
pub use async_client::Client;
#[derive(Builder, Clone)]
#[builder(build_fn(name = "build_unchecked", private))]
pub struct ClientOptions {
#[builder(setter(into, strip_option), default)]
host: Option<String>,
#[builder(default)]
api_key: String,
#[builder(default = "30")]
request_timeout_seconds: u64,
#[builder(setter(into, strip_option), default)]
personal_api_key: Option<String>,
#[builder(default = "false")]
enable_local_evaluation: bool,
#[builder(default = "30")]
poll_interval_seconds: u64,
#[builder(default = "false")]
disabled: bool,
#[builder(default = "false")]
disable_geoip: bool,
#[builder(default = "true")]
is_server: bool,
#[builder(default = "3")]
feature_flags_request_timeout_seconds: u64,
#[builder(default = "false")]
local_evaluation_only: bool,
#[builder(default = "3")]
#[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
pub(crate) max_capture_attempts: u32,
#[builder(default = "200")]
#[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
pub(crate) retry_initial_backoff_ms: u64,
#[builder(default = "30000")]
#[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
pub(crate) retry_max_backoff_ms: u64,
#[builder(default, setter(strip_option))]
pub(crate) capture_compression: Option<CaptureCompression>,
#[cfg(feature = "test-harness")]
#[builder(default, setter(strip_option))]
#[allow(dead_code)]
pub(crate) extra_capture_headers: Option<std::collections::HashMap<String, String>>,
#[builder(setter(skip))]
#[builder(default = "EndpointManager::new(DEFAULT_HOST.to_string())")]
endpoint_manager: EndpointManager,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CaptureDefaults {
pub(crate) disable_geoip: bool,
pub(crate) is_server: bool,
}
impl ClientOptions {
pub(crate) fn capture_defaults(&self) -> CaptureDefaults {
CaptureDefaults {
disable_geoip: self.disable_geoip,
is_server: self.is_server,
}
}
pub(crate) fn endpoints(&self) -> &EndpointManager {
&self.endpoint_manager
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
fn sanitize(mut self) -> Self {
self.api_key = self.api_key.trim().to_string();
if self.api_key.is_empty() {
warn!("api_key is empty after trimming whitespace; disabling PostHog client");
self.disabled = true;
}
self.host = Some(match self.host {
Some(host) => {
let normalized = host.trim().to_string();
if normalized.is_empty() {
DEFAULT_HOST.to_string()
} else {
normalized
}
}
None => DEFAULT_HOST.to_string(),
});
self.personal_api_key = self.personal_api_key.and_then(|personal_api_key| {
let normalized = personal_api_key.trim().to_string();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
});
self.endpoint_manager = EndpointManager::new(
self.host
.clone()
.expect("host is always normalized in sanitize"),
);
self
}
}
impl ClientOptionsBuilder {
pub fn build(&self) -> Result<ClientOptions, ClientOptionsBuilderError> {
Ok(self.build_unchecked()?.sanitize())
}
}
impl From<&str> for ClientOptions {
fn from(api_key: &str) -> Self {
ClientOptionsBuilder::default()
.api_key(api_key.to_string())
.build()
.expect("We always set the API key, so this is infallible")
}
}
impl From<(&str, &str)> for ClientOptions {
fn from((api_key, host): (&str, &str)) -> Self {
ClientOptionsBuilder::default()
.api_key(api_key.to_string())
.host(host.to_string())
.build()
.expect("We always set the API key, so this is infallible")
}
}
#[cfg(test)]
mod tests {
use super::ClientOptionsBuilder;
use crate::endpoints::{EU_INGESTION_ENDPOINT, US_INGESTION_ENDPOINT};
#[test]
fn trims_whitespace_sensitive_options() {
let options = ClientOptionsBuilder::default()
.api_key(" \n test-api-key\t ".to_string())
.host(" \nhttps://eu.posthog.com/\t ")
.personal_api_key(" \n\t ")
.build()
.unwrap();
assert_eq!(options.api_key, "test-api-key");
assert_eq!(options.host.as_deref(), Some("https://eu.posthog.com/"));
assert_eq!(options.personal_api_key, None);
assert_eq!(options.endpoints().api_host(), EU_INGESTION_ENDPOINT);
}
#[test]
fn defaults_blank_host_after_trimming_whitespace() {
let options = ClientOptionsBuilder::default()
.api_key("test-api-key".to_string())
.host(" \n\t ")
.build()
.unwrap();
assert_eq!(options.host.as_deref(), Some(US_INGESTION_ENDPOINT));
assert_eq!(options.endpoints().api_host(), US_INGESTION_ENDPOINT);
}
#[test]
fn builder_allows_missing_api_key_and_disables_client() {
let options = ClientOptionsBuilder::default().build().unwrap();
assert_eq!(options.api_key, "");
assert!(options.is_disabled());
}
#[test]
fn builder_disables_client_for_trim_empty_api_key() {
let options = ClientOptionsBuilder::default()
.api_key(" \n\t ".to_string())
.build()
.unwrap();
assert_eq!(options.api_key, "");
assert!(options.is_disabled());
}
}