From 5f97375940e2c9d812e4afc3fbe40fc8a291e3dd Mon Sep 17 00:00:00 2001 From: Nazar Antoniuk Date: Fri, 5 Sep 2025 09:59:15 +0300 Subject: [PATCH 1/3] make API key optional --- Cargo.toml | 1 + src/client/async_client.rs | 67 ++++++++++++++++++++++++++++++++++++-- src/client/blocking.rs | 67 ++++++++++++++++++++++++++++++++++++-- src/client/mod.rs | 5 +-- 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d7056c6..c9e696c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ uuid = { version = "1.13.2", features = ["serde", "v7"] } [dev-dependencies] dotenv = "0.15.0" ctor = "0.1.26" +tokio = { version = "1.47.1", features = ["rt", "macros"] } [features] default = ["async-client"] diff --git a/src/client/async_client.rs b/src/client/async_client.rs index 56139fb..b47ebfd 100644 --- a/src/client/async_client.rs +++ b/src/client/async_client.rs @@ -23,9 +23,20 @@ pub async fn client>(options: C) -> Client { } impl Client { + /// Returns true if this client is disabled (has no API key). + pub fn is_disabled(&self) -> bool { + self.options.api_key.is_none() + } + /// Capture the provided event, sending it to PostHog. + /// If the client is disabled (no API key), this method returns Ok(()) without sending anything. pub async fn capture(&self, event: Event) -> Result<(), Error> { - let inner_event = InnerEvent::new(event, self.options.api_key.clone()); + if self.is_disabled() { + return Ok(()); + } + + let api_key = self.options.api_key.as_ref().unwrap(); + let inner_event = InnerEvent::new(event, api_key.clone()); let payload = serde_json::to_string(&inner_event).map_err(|e| Error::Serialization(e.to_string()))?; @@ -43,10 +54,16 @@ impl Client { /// Capture a collection of events with a single request. This function may be /// more performant than capturing a list of events individually. + /// If the client is disabled (no API key), this method returns Ok(()) without sending anything. pub async fn capture_batch(&self, events: Vec) -> Result<(), Error> { + if self.is_disabled() { + return Ok(()); + } + + let api_key = self.options.api_key.as_ref().unwrap(); let events: Vec<_> = events .into_iter() - .map(|event| InnerEvent::new(event, self.options.api_key.clone())) + .map(|event| InnerEvent::new(event, api_key.clone())) .collect(); let payload = @@ -63,3 +80,49 @@ impl Client { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ClientOptionsBuilder, Event}; + + #[tokio::test] + async fn test_client_without_api_key_is_disabled() { + let options = ClientOptionsBuilder::default().build().unwrap(); + let client = client(options).await; + assert!(client.is_disabled()); + } + + #[tokio::test] + async fn test_client_with_api_key_is_enabled() { + let options = ClientOptionsBuilder::default() + .api_key(Some("test_key".to_string())) + .build() + .unwrap(); + let client = client(options).await; + assert!(!client.is_disabled()); + } + + #[tokio::test] + async fn test_disabled_client_capture_returns_ok() { + let options = ClientOptionsBuilder::default().build().unwrap(); + let client = client(options).await; + + let event = Event::new("test_event", "user_123"); + let result = client.capture(event).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_disabled_client_capture_batch_returns_ok() { + let options = ClientOptionsBuilder::default().build().unwrap(); + let client = client(options).await; + + let events = vec![ + Event::new("test_event1", "user_123"), + Event::new("test_event2", "user_456"), + ]; + let result = client.capture_batch(events).await; + assert!(result.is_ok()); + } +} diff --git a/src/client/blocking.rs b/src/client/blocking.rs index 0f9af91..9829325 100644 --- a/src/client/blocking.rs +++ b/src/client/blocking.rs @@ -23,9 +23,20 @@ pub fn client>(options: C) -> Client { } impl Client { + /// Returns true if this client is disabled (has no API key). + pub fn is_disabled(&self) -> bool { + self.options.api_key.is_none() + } + /// Capture the provided event, sending it to PostHog. + /// If the client is disabled (no API key), this method returns Ok(()) without sending anything. pub fn capture(&self, event: Event) -> Result<(), Error> { - let inner_event = InnerEvent::new(event, self.options.api_key.clone()); + if self.is_disabled() { + return Ok(()); + } + + let api_key = self.options.api_key.as_ref().unwrap(); + let inner_event = InnerEvent::new(event, api_key.clone()); let payload = serde_json::to_string(&inner_event).map_err(|e| Error::Serialization(e.to_string()))?; @@ -42,10 +53,16 @@ impl Client { /// Capture a collection of events with a single request. This function may be /// more performant than capturing a list of events individually. + /// If the client is disabled (no API key), this method returns Ok(()) without sending anything. pub fn capture_batch(&self, events: Vec) -> Result<(), Error> { + if self.is_disabled() { + return Ok(()); + } + + let api_key = self.options.api_key.as_ref().unwrap(); let events: Vec<_> = events .into_iter() - .map(|event| InnerEvent::new(event, self.options.api_key.clone())) + .map(|event| InnerEvent::new(event, api_key.clone())) .collect(); let payload = @@ -61,3 +78,49 @@ impl Client { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ClientOptionsBuilder, Event}; + + #[test] + fn test_client_without_api_key_is_disabled() { + let options = ClientOptionsBuilder::default().build().unwrap(); + let client = client(options); + assert!(client.is_disabled()); + } + + #[test] + fn test_client_with_api_key_is_enabled() { + let options = ClientOptionsBuilder::default() + .api_key(Some("test_key".to_string())) + .build() + .unwrap(); + let client = client(options); + assert!(!client.is_disabled()); + } + + #[test] + fn test_disabled_client_capture_returns_ok() { + let options = ClientOptionsBuilder::default().build().unwrap(); + let client = client(options); + + let event = Event::new("test_event", "user_123"); + let result = client.capture(event); + assert!(result.is_ok()); + } + + #[test] + fn test_disabled_client_capture_batch_returns_ok() { + let options = ClientOptionsBuilder::default().build().unwrap(); + let client = client(options); + + let events = vec![ + Event::new("test_event1", "user_123"), + Event::new("test_event2", "user_456"), + ]; + let result = client.capture_batch(events); + assert!(result.is_ok()); + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index a60d772..8696bb5 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -19,7 +19,8 @@ pub use async_client::Client; pub struct ClientOptions { #[builder(default = "API_ENDPOINT.to_string()")] api_endpoint: String, - api_key: String, + #[builder(default = "None")] + api_key: Option, #[builder(default = "30")] request_timeout_seconds: u64, @@ -28,7 +29,7 @@ pub struct ClientOptions { impl From<&str> for ClientOptions { fn from(api_key: &str) -> Self { ClientOptionsBuilder::default() - .api_key(api_key.to_string()) + .api_key(Some(api_key.to_string())) .build() .expect("We always set the API key, so this is infallible") } From 565dd3062e4bcccd1774f1d1ce0ddd4c6bcb4b5e Mon Sep 17 00:00:00 2001 From: Nazar Antoniuk Date: Fri, 5 Sep 2025 10:10:54 +0300 Subject: [PATCH 2/3] implement Default for ClientOptions --- src/client/async_client.rs | 18 +++++++++++------- src/client/blocking.rs | 18 +++++++++++------- src/client/mod.rs | 8 ++++++++ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/client/async_client.rs b/src/client/async_client.rs index b47ebfd..c1ee404 100644 --- a/src/client/async_client.rs +++ b/src/client/async_client.rs @@ -88,8 +88,10 @@ mod tests { #[tokio::test] async fn test_client_without_api_key_is_disabled() { - let options = ClientOptionsBuilder::default().build().unwrap(); + let options = ClientOptions::default(); + let client = client(options).await; + assert!(client.is_disabled()); } @@ -99,30 +101,32 @@ mod tests { .api_key(Some("test_key".to_string())) .build() .unwrap(); + let client = client(options).await; + assert!(!client.is_disabled()); } #[tokio::test] async fn test_disabled_client_capture_returns_ok() { - let options = ClientOptionsBuilder::default().build().unwrap(); - let client = client(options).await; - + let client = client(ClientOptions::default()).await; let event = Event::new("test_event", "user_123"); + let result = client.capture(event).await; + assert!(result.is_ok()); } #[tokio::test] async fn test_disabled_client_capture_batch_returns_ok() { - let options = ClientOptionsBuilder::default().build().unwrap(); - let client = client(options).await; - + let client = client(ClientOptions::default()).await; let events = vec![ Event::new("test_event1", "user_123"), Event::new("test_event2", "user_456"), ]; + let result = client.capture_batch(events).await; + assert!(result.is_ok()); } } diff --git a/src/client/blocking.rs b/src/client/blocking.rs index 9829325..4740c48 100644 --- a/src/client/blocking.rs +++ b/src/client/blocking.rs @@ -86,8 +86,10 @@ mod tests { #[test] fn test_client_without_api_key_is_disabled() { - let options = ClientOptionsBuilder::default().build().unwrap(); + let options = ClientOptions::default(); + let client = client(options); + assert!(client.is_disabled()); } @@ -97,30 +99,32 @@ mod tests { .api_key(Some("test_key".to_string())) .build() .unwrap(); + let client = client(options); + assert!(!client.is_disabled()); } #[test] fn test_disabled_client_capture_returns_ok() { - let options = ClientOptionsBuilder::default().build().unwrap(); - let client = client(options); - + let client = client(ClientOptions::default()); let event = Event::new("test_event", "user_123"); + let result = client.capture(event); + assert!(result.is_ok()); } #[test] fn test_disabled_client_capture_batch_returns_ok() { - let options = ClientOptionsBuilder::default().build().unwrap(); - let client = client(options); - + let client = client(ClientOptions::default()); let events = vec![ Event::new("test_event1", "user_123"), Event::new("test_event2", "user_456"), ]; + let result = client.capture_batch(events); + assert!(result.is_ok()); } } diff --git a/src/client/mod.rs b/src/client/mod.rs index 8696bb5..dd8aabd 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -26,6 +26,14 @@ pub struct ClientOptions { request_timeout_seconds: u64, } +impl Default for ClientOptions { + fn default() -> Self { + ClientOptionsBuilder::default() + .build() + .expect("Default ClientOptions should always build successfully") + } +} + impl From<&str> for ClientOptions { fn from(api_key: &str) -> Self { ClientOptionsBuilder::default() From 5604973b047634475dfbf2f1dba9170d84685565 Mon Sep 17 00:00:00 2001 From: Nazar Antoniuk Date: Fri, 5 Sep 2025 10:16:11 +0300 Subject: [PATCH 3/3] update readme --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 3fd7966..d8189d9 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,21 @@ event.insert_prop("key2", vec!["a", "b"]).unwrap(); client.capture(event).unwrap(); ``` + +## Disabled Client + +The client can be initialized without an API key, which creates a disabled client. This is useful for development environments or when you need to conditionally disable event tracking (e.g., based on user privacy settings). + +```rust +// Create a disabled client (no API key). +let client = posthog_rs::client(posthog_rs::ClientOptions::default()); + +// Events can be captured but won't be sent to PostHog. +let event = posthog_rs::Event::new("test", "1234"); +client.capture(event).unwrap(); // Returns Ok(()) without sending anything. + +// Check if client is disabled. +if client.is_disabled() { + println!("Client is disabled - events will not be sent"); +} +```