From 3db90c65ca7fec3bb38b3aff1d8155618574b846 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 01:20:10 +0000 Subject: [PATCH 01/16] Split up components in to individual files --- src/client.rs | 43 +++++++++++ src/client_options.rs | 42 +++++++++++ src/error.rs | 18 +++++ src/event.rs | 69 ++++++++++++++++++ src/lib.rs | 165 +++--------------------------------------- tests/basic.rs | 17 +++++ 6 files changed, 198 insertions(+), 156 deletions(-) create mode 100644 src/client.rs create mode 100644 src/client_options.rs create mode 100644 src/error.rs create mode 100644 src/event.rs create mode 100644 tests/basic.rs diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..8191475 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,43 @@ +use reqwest::blocking::Client as HttpClient; +use reqwest::header::CONTENT_TYPE; + +use crate::client_options::ClientOptions; +use crate::error::Error; +use crate::event::{Event, InnerEvent}; + +pub struct Client { + options: ClientOptions, + http_client: HttpClient, +} + +impl Client { + pub(crate) fn new(options: ClientOptions) -> Self { + let http_client = HttpClient::builder() + .timeout(Some(options.timeout)) + .build() + .unwrap(); // Unwrap here is as safe as `HttpClient::new` + Client { + options, + http_client, + } + } + + pub fn capture(&self, event: Event) -> Result<(), Error> { + let inner_event = InnerEvent::new(event, self.options.api_key.clone()); + let _res = self + .http_client + .post(self.options.api_endpoint.clone()) + .header(CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) + .send() + .map_err(|e| Error::Connection(e.to_string()))?; + Ok(()) + } + + pub fn capture_batch(&self, events: Vec) -> Result<(), Error> { + for event in events { + self.capture(event)?; + } + Ok(()) + } +} diff --git a/src/client_options.rs b/src/client_options.rs new file mode 100644 index 0000000..f9dee95 --- /dev/null +++ b/src/client_options.rs @@ -0,0 +1,42 @@ +use std::time::Duration; + +use crate::client::Client; + +const API_ENDPOINT: &str = "https://app.posthog.com/capture/"; +const TIMEOUT: Duration = Duration::from_millis(800); // This should be specified by the user + +pub struct ClientOptions { + pub(crate) api_endpoint: String, + pub(crate) api_key: String, + pub(crate) timeout: Duration, +} + +impl ClientOptions { + pub fn new(api_key: impl ToString) -> ClientOptions { + ClientOptions { + api_endpoint: API_ENDPOINT.to_string(), + api_key: api_key.to_string(), + timeout: TIMEOUT, + } + } + + pub fn api_endpoint(&mut self, api_endpoint: impl ToString) -> &mut Self { + self.api_endpoint = api_endpoint.to_string(); + self + } + + pub fn timeout(&mut self, timeout: Duration) -> &mut Self { + self.timeout = timeout; + self + } + + pub fn build(self) -> Client { + Client::new(self) + } +} + +impl From<&str> for ClientOptions { + fn from(api_key: &str) -> Self { + ClientOptions::new(api_key) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6474bd4 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,18 @@ +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum Error { + Connection(String), + Serialization(String), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Error::Connection(msg) => write!(f, "Connection Error: {}", msg), + Error::Serialization(msg) => write!(f, "Serialization Error: {}", msg), + } + } +} + +impl std::error::Error for Error {} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..42efb96 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,69 @@ +use chrono::NaiveDateTime; +use serde::Serialize; +use std::collections::HashMap; + +use crate::error::Error; + +// This exists so that the client doesn't have to specify the API key over and over +#[derive(Serialize)] +pub(crate) struct InnerEvent { + api_key: String, + event: String, + properties: Properties, + timestamp: Option, +} + +impl InnerEvent { + pub(crate) fn new(event: Event, api_key: String) -> Self { + Self { + api_key, + event: event.event, + properties: event.properties, + timestamp: event.timestamp, + } + } +} + +#[derive(Serialize, Debug, PartialEq, Eq)] +pub struct Event { + event: String, + properties: Properties, + timestamp: Option, +} + +#[derive(Serialize, Debug, PartialEq, Eq)] +pub struct Properties { + distinct_id: String, + props: HashMap, +} + +impl Properties { + fn new>(distinct_id: S) -> Self { + Self { + distinct_id: distinct_id.into(), + props: Default::default(), + } + } +} + +impl Event { + pub fn new>(event: S, distinct_id: S) -> Self { + Self { + event: event.into(), + properties: Properties::new(distinct_id), + timestamp: None, + } + } + + /// Errors if `prop` fails to serialize + pub fn insert_prop, P: Serialize>( + &mut self, + key: K, + prop: P, + ) -> Result<(), Error> { + let as_json = + serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?; + let _ = self.properties.props.insert(key.into(), as_json); + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 98630fa..b527fea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,160 +1,13 @@ -use std::collections::HashMap; -use std::fmt::{Display, Formatter}; -use chrono::{NaiveDateTime}; -use reqwest::blocking::Client as HttpClient; -use reqwest::header::CONTENT_TYPE; -use serde::{Serialize}; -use std::time::Duration; +mod client; +mod client_options; +mod error; +mod event; -extern crate serde_json; - -const API_ENDPOINT: &str = "https://app.posthog.com/capture/"; -const TIMEOUT: &Duration = &Duration::from_millis(800); // This should be specified by the user +pub use client::Client; +pub use client_options::ClientOptions; +pub use error::Error; +pub use event::{Event, Properties}; pub fn client>(options: C) -> Client { - let client = HttpClient::builder().timeout(Some(TIMEOUT.clone())).build().unwrap(); // Unwrap here is as safe as `HttpClient::new` - Client { - options: options.into(), - client, - } -} - -impl Display for Error { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Error::Connection(msg) => write!(f, "Connection Error: {}", msg), - Error::Serialization(msg) => write!(f, "Serialization Error: {}", msg) - } - } -} - -impl std::error::Error for Error { - -} - -#[derive(Debug)] -pub enum Error { - Connection(String), - Serialization(String) -} - -pub struct ClientOptions { - api_endpoint: String, - api_key: String, -} - -impl From<&str> for ClientOptions { - fn from(api_key: &str) -> Self { - ClientOptions { - api_endpoint: API_ENDPOINT.to_string(), - api_key: api_key.to_string(), - } - } -} - -pub struct Client { - options: ClientOptions, - client: HttpClient, -} - -impl Client { - pub fn capture(&self, event: Event) -> Result<(), Error> { - let inner_event = InnerEvent::new(event, self.options.api_key.clone()); - let _res = self.client.post(self.options.api_endpoint.clone()) - .header(CONTENT_TYPE, "application/json") - .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) - .send() - .map_err(|e| Error::Connection(e.to_string()))?; - Ok(()) - } - - pub fn capture_batch(&self, events: Vec) -> Result<(), Error> { - for event in events { - self.capture(event)?; - } - Ok(()) - } -} - -// This exists so that the client doesn't have to specify the API key over and over -#[derive(Serialize)] -struct InnerEvent { - api_key: String, - event: String, - properties: Properties, - timestamp: Option, + options.into().build() } - -impl InnerEvent { - fn new(event: Event, api_key: String) -> Self { - Self { - api_key, - event: event.event, - properties: event.properties, - timestamp: event.timestamp, - } - } -} - - -#[derive(Serialize, Debug, PartialEq, Eq)] -pub struct Event { - event: String, - properties: Properties, - timestamp: Option, -} - -#[derive(Serialize, Debug, PartialEq, Eq)] -pub struct Properties { - distinct_id: String, - props: HashMap, -} - -impl Properties { - fn new>(distinct_id: S) -> Self { - Self { - distinct_id: distinct_id.into(), - props: Default::default() - } - } -} - -impl Event { - pub fn new>(event: S, distinct_id: S) -> Self { - Self { - event: event.into(), - properties: Properties::new(distinct_id), - timestamp: None - } - } - - /// Errors if `prop` fails to serialize - pub fn insert_prop, P: Serialize>(&mut self, key: K, prop: P) -> Result<(), Error> { - let as_json = serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?; - let _ = self.properties.props.insert(key.into(), as_json); - Ok(()) - } -} - - -#[cfg(test)] -pub mod tests { - use super::*; - use chrono::{Utc}; - - #[test] - fn get_client() { - let client = crate::client(env!("POSTHOG_API_KEY")); - - let mut child_map = HashMap::new(); - child_map.insert("child_key1", "child_value1"); - - - let mut event = Event::new("test", "1234"); - event.insert_prop("key1", "value1").unwrap(); - event.insert_prop("key2", vec!["a", "b"]).unwrap(); - event.insert_prop("key3", child_map).unwrap(); - - client.capture(event).unwrap(); - } -} \ No newline at end of file diff --git a/tests/basic.rs b/tests/basic.rs new file mode 100644 index 0000000..4387a69 --- /dev/null +++ b/tests/basic.rs @@ -0,0 +1,17 @@ +use super::*; +use chrono::Utc; + +#[test] +fn get_client() { + let client = crate::client(env!("POSTHOG_API_KEY")); + + let mut child_map = HashMap::new(); + child_map.insert("child_key1", "child_value1"); + + let mut event = Event::new("test", "1234"); + event.insert_prop("key1", "value1").unwrap(); + event.insert_prop("key2", vec!["a", "b"]).unwrap(); + event.insert_prop("key3", child_map).unwrap(); + + client.capture(event).unwrap(); +} From 8cb2dffa7396d7662bd6152370a5d9f72100c4cc Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 01:22:29 +0000 Subject: [PATCH 02/16] Fix tests --- tests/basic.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/basic.rs b/tests/basic.rs index 4387a69..8d1e418 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -1,9 +1,9 @@ -use super::*; -use chrono::Utc; +use posthog_rs::Event; +use std::collections::HashMap; #[test] fn get_client() { - let client = crate::client(env!("POSTHOG_API_KEY")); + let client = posthog_rs::client(env!("POSTHOG_API_KEY")); let mut child_map = HashMap::new(); child_map.insert("child_key1", "child_value1"); From dca324c57a01eb34054f618d011cfe7cfe021f22 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 01:41:43 +0000 Subject: [PATCH 03/16] Split up PostHog core and PostHog sync --- Cargo.toml | 20 +++++--------------- core/Cargo.toml | 13 +++++++++++++ core/src/error.rs | 8 ++++++++ {src => core/src}/event.rs | 6 +++--- core/src/lib.rs | 2 ++ src/error.rs | 18 ------------------ sync/Cargo.toml | 16 ++++++++++++++++ {src => sync/src}/client.rs | 4 ++-- {src => sync/src}/client_options.rs | 0 sync/src/error.rs | 7 +++++++ {src => sync/src}/lib.rs | 4 ++-- {tests => sync/tests}/basic.rs | 0 12 files changed, 58 insertions(+), 40 deletions(-) create mode 100644 core/Cargo.toml create mode 100644 core/src/error.rs rename {src => core/src}/event.rs (89%) create mode 100644 core/src/lib.rs delete mode 100644 src/error.rs create mode 100644 sync/Cargo.toml rename {src => sync/src}/client.rs (91%) rename {src => sync/src}/client_options.rs (100%) create mode 100644 sync/src/error.rs rename {src => sync/src}/lib.rs (81%) rename {tests => sync/tests}/basic.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 2378cf3..69a1c34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,6 @@ -[package] -name = "posthog-rs" -license = "MIT" -version = "0.2.3" -authors = ["christos "] -description = "An unofficial Rust client for Posthog (https://posthog.com/)." -repository = "https://github.com/openquery-io/posthog-rs" -edition = "2018" +[workspace] +members = [ + "core", + "sync", +] -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -reqwest = { version = "0.11.3", default-features = false, features = ["blocking", "rustls-tls"] } -serde = { version = "1.0.125", features = ["derive"] } -chrono = {version = "0.4.19", features = ["serde"] } -serde_json = "1.0.64" diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..176e7c3 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "posthog-core" +license = "MIT" +version = "0.1.0" +description = "An unofficial Rust client for Posthog (https://posthog.com/)." +repository = "https://github.com/openquery-io/posthog-rs" +edition = "2021" + +[dependencies] +chrono = {version = "0.4.19", features = ["serde"] } +serde = { version = "1.0.125", features = ["derive"] } +serde_json = "1.0.64" +thiserror = "1.0.38" diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..88312e4 --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,8 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("serialization: {source}")] + Serialization { + #[from] + source: serde_json::Error, + }, +} diff --git a/src/event.rs b/core/src/event.rs similarity index 89% rename from src/event.rs rename to core/src/event.rs index 42efb96..5fd1f9f 100644 --- a/src/event.rs +++ b/core/src/event.rs @@ -6,7 +6,7 @@ use crate::error::Error; // This exists so that the client doesn't have to specify the API key over and over #[derive(Serialize)] -pub(crate) struct InnerEvent { +pub struct InnerEvent { api_key: String, event: String, properties: Properties, @@ -14,7 +14,7 @@ pub(crate) struct InnerEvent { } impl InnerEvent { - pub(crate) fn new(event: Event, api_key: String) -> Self { + pub fn new(event: Event, api_key: String) -> Self { Self { api_key, event: event.event, @@ -62,7 +62,7 @@ impl Event { prop: P, ) -> Result<(), Error> { let as_json = - serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?; + serde_json::to_value(prop).map_err(|source| Error::Serialization { source })?; let _ = self.properties.props.insert(key.into(), as_json); Ok(()) } diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..1610d23 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod event; diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 6474bd4..0000000 --- a/src/error.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::fmt::{Display, Formatter}; - -#[derive(Debug)] -pub enum Error { - Connection(String), - Serialization(String), -} - -impl Display for Error { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Error::Connection(msg) => write!(f, "Connection Error: {}", msg), - Error::Serialization(msg) => write!(f, "Serialization Error: {}", msg), - } - } -} - -impl std::error::Error for Error {} diff --git a/sync/Cargo.toml b/sync/Cargo.toml new file mode 100644 index 0000000..f0b4079 --- /dev/null +++ b/sync/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "posthog-rs" +license = "MIT" +version = "0.2.3" +authors = ["christos "] +description = "An unofficial Rust client for Posthog (https://posthog.com/)." +repository = "https://github.com/openquery-io/posthog-rs" +edition = "2021" + +[dependencies] +posthog-core = { path = "../core" } +chrono = {version = "0.4.19", features = ["serde"] } +reqwest = { version = "0.11.3", default-features = false, features = ["blocking", "rustls-tls"] } +serde = { version = "1.0.125", features = ["derive"] } +serde_json = "1.0.64" +thiserror = "1.0.38" diff --git a/src/client.rs b/sync/src/client.rs similarity index 91% rename from src/client.rs rename to sync/src/client.rs index 8191475..ee80783 100644 --- a/src/client.rs +++ b/sync/src/client.rs @@ -1,9 +1,9 @@ +use posthog_core::event::{Event, InnerEvent}; use reqwest::blocking::Client as HttpClient; use reqwest::header::CONTENT_TYPE; use crate::client_options::ClientOptions; use crate::error::Error; -use crate::event::{Event, InnerEvent}; pub struct Client { options: ClientOptions, @@ -30,7 +30,7 @@ impl Client { .header(CONTENT_TYPE, "application/json") .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) .send() - .map_err(|e| Error::Connection(e.to_string()))?; + .map_err(|source| Error::Connection { source })?; Ok(()) } diff --git a/src/client_options.rs b/sync/src/client_options.rs similarity index 100% rename from src/client_options.rs rename to sync/src/client_options.rs diff --git a/sync/src/error.rs b/sync/src/error.rs new file mode 100644 index 0000000..ea418a6 --- /dev/null +++ b/sync/src/error.rs @@ -0,0 +1,7 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{source}")] + PostHogCore { source: posthog_core::error::Error }, + #[error("connection: {source}")] + Connection { source: reqwest::Error }, +} diff --git a/src/lib.rs b/sync/src/lib.rs similarity index 81% rename from src/lib.rs rename to sync/src/lib.rs index b527fea..de4bf5f 100644 --- a/src/lib.rs +++ b/sync/src/lib.rs @@ -1,12 +1,12 @@ mod client; mod client_options; mod error; -mod event; pub use client::Client; pub use client_options::ClientOptions; pub use error::Error; -pub use event::{Event, Properties}; + +pub use posthog_core::event::{Event, Properties}; pub fn client>(options: C) -> Client { options.into().build() diff --git a/tests/basic.rs b/sync/tests/basic.rs similarity index 100% rename from tests/basic.rs rename to sync/tests/basic.rs From c93b30d147432db2a5c230e8f1915492dfde9393 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 01:55:36 +0000 Subject: [PATCH 04/16] Add async client to PostHog --- Cargo.toml | 1 + async/Cargo.toml | 15 +++++++++++++ async/src/client.rs | 45 +++++++++++++++++++++++++++++++++++++ async/src/client_options.rs | 42 ++++++++++++++++++++++++++++++++++ async/src/error.rs | 7 ++++++ async/src/lib.rs | 13 +++++++++++ async/tests/basic.rs | 17 ++++++++++++++ sync/Cargo.toml | 1 - sync/src/client.rs | 1 + 9 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 async/Cargo.toml create mode 100644 async/src/client.rs create mode 100644 async/src/client_options.rs create mode 100644 async/src/error.rs create mode 100644 async/src/lib.rs create mode 100644 async/tests/basic.rs diff --git a/Cargo.toml b/Cargo.toml index 69a1c34..aad08c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "async", "core", "sync", ] diff --git a/async/Cargo.toml b/async/Cargo.toml new file mode 100644 index 0000000..b2fb684 --- /dev/null +++ b/async/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "async-posthog" +license = "MIT" +version = "0.2.3" +description = "An unofficial Rust client for Posthog (https://posthog.com/)." +repository = "https://github.com/openquery-io/posthog-rs" +edition = "2021" + +[dependencies] +posthog-core = { path = "../core" } +reqwest = { version = "0.11.3", default-features = false, features = ["rustls-tls"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1.0.125", features = ["derive"] } +serde_json = "1.0.64" +thiserror = "1.0.38" diff --git a/async/src/client.rs b/async/src/client.rs new file mode 100644 index 0000000..2f98f5d --- /dev/null +++ b/async/src/client.rs @@ -0,0 +1,45 @@ +use posthog_core::event::{Event, InnerEvent}; +use reqwest::header::CONTENT_TYPE; +use reqwest::Client as HttpClient; + +use crate::client_options::ClientOptions; +use crate::error::Error; + +pub struct Client { + options: ClientOptions, + http_client: HttpClient, +} + +impl Client { + pub(crate) fn new(options: ClientOptions) -> Self { + let http_client = HttpClient::builder() + .timeout(options.timeout) + .build() + .unwrap(); // Unwrap here is as safe as `HttpClient::new` + Client { + options, + http_client, + } + } + + pub async fn capture(&self, event: Event) -> Result<(), Error> { + let inner_event = InnerEvent::new(event, self.options.api_key.clone()); + let _res = self + .http_client + .post(self.options.api_endpoint.clone()) + .header(CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) + .send() + .await + .map_err(|source| Error::Connection { source })?; + Ok(()) + } + + pub async fn capture_batch(&self, events: Vec) -> Result<(), Error> { + // TODO: Use batch endpoint + for event in events { + self.capture(event).await?; + } + Ok(()) + } +} diff --git a/async/src/client_options.rs b/async/src/client_options.rs new file mode 100644 index 0000000..f9dee95 --- /dev/null +++ b/async/src/client_options.rs @@ -0,0 +1,42 @@ +use std::time::Duration; + +use crate::client::Client; + +const API_ENDPOINT: &str = "https://app.posthog.com/capture/"; +const TIMEOUT: Duration = Duration::from_millis(800); // This should be specified by the user + +pub struct ClientOptions { + pub(crate) api_endpoint: String, + pub(crate) api_key: String, + pub(crate) timeout: Duration, +} + +impl ClientOptions { + pub fn new(api_key: impl ToString) -> ClientOptions { + ClientOptions { + api_endpoint: API_ENDPOINT.to_string(), + api_key: api_key.to_string(), + timeout: TIMEOUT, + } + } + + pub fn api_endpoint(&mut self, api_endpoint: impl ToString) -> &mut Self { + self.api_endpoint = api_endpoint.to_string(); + self + } + + pub fn timeout(&mut self, timeout: Duration) -> &mut Self { + self.timeout = timeout; + self + } + + pub fn build(self) -> Client { + Client::new(self) + } +} + +impl From<&str> for ClientOptions { + fn from(api_key: &str) -> Self { + ClientOptions::new(api_key) + } +} diff --git a/async/src/error.rs b/async/src/error.rs new file mode 100644 index 0000000..ea418a6 --- /dev/null +++ b/async/src/error.rs @@ -0,0 +1,7 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{source}")] + PostHogCore { source: posthog_core::error::Error }, + #[error("connection: {source}")] + Connection { source: reqwest::Error }, +} diff --git a/async/src/lib.rs b/async/src/lib.rs new file mode 100644 index 0000000..de4bf5f --- /dev/null +++ b/async/src/lib.rs @@ -0,0 +1,13 @@ +mod client; +mod client_options; +mod error; + +pub use client::Client; +pub use client_options::ClientOptions; +pub use error::Error; + +pub use posthog_core::event::{Event, Properties}; + +pub fn client>(options: C) -> Client { + options.into().build() +} diff --git a/async/tests/basic.rs b/async/tests/basic.rs new file mode 100644 index 0000000..d40ca8c --- /dev/null +++ b/async/tests/basic.rs @@ -0,0 +1,17 @@ +use async_posthog::Event; +use std::collections::HashMap; + +#[tokio::test] +async fn get_client() { + let client = async_posthog::client(env!("POSTHOG_API_KEY")); + + let mut child_map = HashMap::new(); + child_map.insert("child_key1", "child_value1"); + + let mut event = Event::new("test", "1234"); + event.insert_prop("key1", "value1").unwrap(); + event.insert_prop("key2", vec!["a", "b"]).unwrap(); + event.insert_prop("key3", child_map).unwrap(); + + client.capture(event).await.unwrap(); +} diff --git a/sync/Cargo.toml b/sync/Cargo.toml index f0b4079..fd192ee 100644 --- a/sync/Cargo.toml +++ b/sync/Cargo.toml @@ -9,7 +9,6 @@ edition = "2021" [dependencies] posthog-core = { path = "../core" } -chrono = {version = "0.4.19", features = ["serde"] } reqwest = { version = "0.11.3", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1.0.125", features = ["derive"] } serde_json = "1.0.64" diff --git a/sync/src/client.rs b/sync/src/client.rs index ee80783..26623d4 100644 --- a/sync/src/client.rs +++ b/sync/src/client.rs @@ -35,6 +35,7 @@ impl Client { } pub fn capture_batch(&self, events: Vec) -> Result<(), Error> { + // TODO: Use batch endpoint for event in events { self.capture(event)?; } From 25e23b2aea941642a80b3d3bcb8af78189345c85 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 01:59:59 +0000 Subject: [PATCH 05/16] Add test for batch events --- async/tests/basic.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/async/tests/basic.rs b/async/tests/basic.rs index d40ca8c..2afca46 100644 --- a/async/tests/basic.rs +++ b/async/tests/basic.rs @@ -1,9 +1,13 @@ use async_posthog::Event; use std::collections::HashMap; +fn build_client() -> async_posthog::Client { + async_posthog::client(env!("POSTHOG_API_KEY")) +} + #[tokio::test] -async fn get_client() { - let client = async_posthog::client(env!("POSTHOG_API_KEY")); +async fn capture() { + let client = build_client(); let mut child_map = HashMap::new(); child_map.insert("child_key1", "child_value1"); @@ -15,3 +19,24 @@ async fn get_client() { client.capture(event).await.unwrap(); } + +#[tokio::test] +async fn capture_batch() { + let client = build_client(); + + let events = (0..16) + .map(|_| { + let mut child_map = HashMap::new(); + child_map.insert("child_key1", "child_value1"); + + let mut event = Event::new("test", "1234"); + event.insert_prop("key1", "value1").unwrap(); + event.insert_prop("key2", vec!["a", "b"]).unwrap(); + event.insert_prop("key3", child_map).unwrap(); + + event + }) + .collect::>(); + + client.capture_batch(events).await.unwrap(); +} From cb1529208dcd6da768b5ca4d508f85d472dcb02b Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 03:26:25 +0000 Subject: [PATCH 06/16] Use batch endpoint to capture events --- async/src/client.rs | 15 ++++++++++----- core/src/event.rs | 25 +++++++++++++++---------- sync/src/client.rs | 14 +++++++++----- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/async/src/client.rs b/async/src/client.rs index 2f98f5d..14e9fa0 100644 --- a/async/src/client.rs +++ b/async/src/client.rs @@ -1,4 +1,4 @@ -use posthog_core::event::{Event, InnerEvent}; +use posthog_core::event::{Event, InnerEvent, InnerEventBatch}; use reqwest::header::CONTENT_TYPE; use reqwest::Client as HttpClient; @@ -36,10 +36,15 @@ impl Client { } pub async fn capture_batch(&self, events: Vec) -> Result<(), Error> { - // TODO: Use batch endpoint - for event in events { - self.capture(event).await?; - } + let inner_event = InnerEventBatch::new(events, self.options.api_key.clone()); + let _res = self + .http_client + .post(self.options.api_endpoint.clone()) + .header(CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) + .send() + .await + .map_err(|source| Error::Connection { source })?; Ok(()) } } diff --git a/core/src/event.rs b/core/src/event.rs index 5fd1f9f..dbde614 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -4,23 +4,28 @@ use std::collections::HashMap; use crate::error::Error; -// This exists so that the client doesn't have to specify the API key over and over #[derive(Serialize)] pub struct InnerEvent { api_key: String, - event: String, - properties: Properties, - timestamp: Option, + #[serde(flatten)] + event: Event, } impl InnerEvent { pub fn new(event: Event, api_key: String) -> Self { - Self { - api_key, - event: event.event, - properties: event.properties, - timestamp: event.timestamp, - } + Self { api_key, event } + } +} + +#[derive(Serialize)] +pub struct InnerEventBatch { + api_key: String, + batch: Vec, +} + +impl InnerEventBatch { + pub fn new(batch: Vec, api_key: String) -> Self { + Self { api_key, batch } } } diff --git a/sync/src/client.rs b/sync/src/client.rs index 26623d4..27edaca 100644 --- a/sync/src/client.rs +++ b/sync/src/client.rs @@ -1,4 +1,4 @@ -use posthog_core::event::{Event, InnerEvent}; +use posthog_core::event::{Event, InnerEvent, InnerEventBatch}; use reqwest::blocking::Client as HttpClient; use reqwest::header::CONTENT_TYPE; @@ -35,10 +35,14 @@ impl Client { } pub fn capture_batch(&self, events: Vec) -> Result<(), Error> { - // TODO: Use batch endpoint - for event in events { - self.capture(event)?; - } + let inner_event = InnerEventBatch::new(events, self.options.api_key.clone()); + let _res = self + .http_client + .post(self.options.api_endpoint.clone()) + .header(CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) + .send() + .map_err(|source| Error::Connection { source })?; Ok(()) } } From 92e3957b95cc9dc7222146299cc41bcd804c36b0 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 05:00:56 +0000 Subject: [PATCH 07/16] Make async backend handle non-200 errors correctly --- async/Cargo.toml | 2 +- async/src/client.rs | 48 +++++++++++++++++++++++-------------- async/src/client_options.rs | 2 +- async/src/error.rs | 8 +++++-- sync/src/client_options.rs | 2 +- 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/async/Cargo.toml b/async/Cargo.toml index b2fb684..8c9840d 100644 --- a/async/Cargo.toml +++ b/async/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" [dependencies] posthog-core = { path = "../core" } -reqwest = { version = "0.11.3", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.11.3", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["full"] } serde = { version = "1.0.125", features = ["derive"] } serde_json = "1.0.64" diff --git a/async/src/client.rs b/async/src/client.rs index 14e9fa0..f4cdbee 100644 --- a/async/src/client.rs +++ b/async/src/client.rs @@ -1,6 +1,6 @@ use posthog_core::event::{Event, InnerEvent, InnerEventBatch}; -use reqwest::header::CONTENT_TYPE; -use reqwest::Client as HttpClient; +use reqwest::{Client as HttpClient, Method}; +use serde::{de::DeserializeOwned, Serialize}; use crate::client_options::ClientOptions; use crate::error::Error; @@ -22,29 +22,41 @@ impl Client { } } - pub async fn capture(&self, event: Event) -> Result<(), Error> { - let inner_event = InnerEvent::new(event, self.options.api_key.clone()); - let _res = self + async fn send_request, Body: Serialize, Res: DeserializeOwned>( + &self, + method: Method, + path: P, + body: &Body, + ) -> Result { + let res = self .http_client - .post(self.options.api_endpoint.clone()) - .header(CONTENT_TYPE, "application/json") - .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) + .request( + method, + format!("{}{}", self.options.api_endpoint, path.as_ref()), + ) + .json(body) .send() .await - .map_err(|source| Error::Connection { source })?; + .map_err(|source| Error::SendRequest { source })? + .error_for_status() + .map_err(|source| Error::ResponseStatus { source })? + .json::() + .await + .map_err(|source| Error::DecodeResponse { source })?; + Ok(res) + } + + pub async fn capture(&self, event: Event) -> Result<(), Error> { + let inner_event = InnerEvent::new(event, self.options.api_key.clone()); + self.send_request::<_, _, serde_json::Value>(Method::POST, "/capture/", &inner_event) + .await?; Ok(()) } pub async fn capture_batch(&self, events: Vec) -> Result<(), Error> { - let inner_event = InnerEventBatch::new(events, self.options.api_key.clone()); - let _res = self - .http_client - .post(self.options.api_endpoint.clone()) - .header(CONTENT_TYPE, "application/json") - .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) - .send() - .await - .map_err(|source| Error::Connection { source })?; + let inner_event_batch = InnerEventBatch::new(events, self.options.api_key.clone()); + self.send_request::<_, _, serde_json::Value>(Method::POST, "/batch/", &inner_event_batch) + .await?; Ok(()) } } diff --git a/async/src/client_options.rs b/async/src/client_options.rs index f9dee95..b4019cc 100644 --- a/async/src/client_options.rs +++ b/async/src/client_options.rs @@ -2,7 +2,7 @@ use std::time::Duration; use crate::client::Client; -const API_ENDPOINT: &str = "https://app.posthog.com/capture/"; +const API_ENDPOINT: &str = "https://app.posthog.com"; const TIMEOUT: Duration = Duration::from_millis(800); // This should be specified by the user pub struct ClientOptions { diff --git a/async/src/error.rs b/async/src/error.rs index ea418a6..74024e6 100644 --- a/async/src/error.rs +++ b/async/src/error.rs @@ -2,6 +2,10 @@ pub enum Error { #[error("{source}")] PostHogCore { source: posthog_core::error::Error }, - #[error("connection: {source}")] - Connection { source: reqwest::Error }, + #[error("send request: {source}")] + SendRequest { source: reqwest::Error }, + #[error("response status: {source}")] + ResponseStatus { source: reqwest::Error }, + #[error("decode response: {source}")] + DecodeResponse { source: reqwest::Error }, } diff --git a/sync/src/client_options.rs b/sync/src/client_options.rs index f9dee95..e150ecb 100644 --- a/sync/src/client_options.rs +++ b/sync/src/client_options.rs @@ -2,7 +2,7 @@ use std::time::Duration; use crate::client::Client; -const API_ENDPOINT: &str = "https://app.posthog.com/capture/"; +const API_ENDPOINT: &str = "https://app.posthog.com/"; const TIMEOUT: Duration = Duration::from_millis(800); // This should be specified by the user pub struct ClientOptions { From 263392b2579c90f93496cb48d6e47698286c610a Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 05:04:44 +0000 Subject: [PATCH 08/16] Fix batch requests in sync client --- sync/Cargo.toml | 2 +- sync/src/client.rs | 44 +++++++++++++++++++++++--------------- sync/src/client_options.rs | 2 +- sync/src/error.rs | 8 +++++-- sync/tests/basic.rs | 29 +++++++++++++++++++++++-- 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/sync/Cargo.toml b/sync/Cargo.toml index fd192ee..75fdf9b 100644 --- a/sync/Cargo.toml +++ b/sync/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" [dependencies] posthog-core = { path = "../core" } -reqwest = { version = "0.11.3", default-features = false, features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.11.3", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0.125", features = ["derive"] } serde_json = "1.0.64" thiserror = "1.0.38" diff --git a/sync/src/client.rs b/sync/src/client.rs index 27edaca..ed6e2bc 100644 --- a/sync/src/client.rs +++ b/sync/src/client.rs @@ -1,6 +1,6 @@ use posthog_core::event::{Event, InnerEvent, InnerEventBatch}; -use reqwest::blocking::Client as HttpClient; -use reqwest::header::CONTENT_TYPE; +use reqwest::{blocking::Client as HttpClient, Method}; +use serde::{de::DeserializeOwned, Serialize}; use crate::client_options::ClientOptions; use crate::error::Error; @@ -22,27 +22,37 @@ impl Client { } } - pub fn capture(&self, event: Event) -> Result<(), Error> { - let inner_event = InnerEvent::new(event, self.options.api_key.clone()); - let _res = self + fn send_request, Body: Serialize, Res: DeserializeOwned>( + &self, + method: Method, + path: P, + body: &Body, + ) -> Result { + let res = self .http_client - .post(self.options.api_endpoint.clone()) - .header(CONTENT_TYPE, "application/json") - .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) + .request( + method, + format!("{}{}", self.options.api_endpoint, path.as_ref()), + ) + .json(body) .send() - .map_err(|source| Error::Connection { source })?; + .map_err(|source| Error::SendRequest { source })? + .error_for_status() + .map_err(|source| Error::ResponseStatus { source })? + .json::() + .map_err(|source| Error::DecodeResponse { source })?; + Ok(res) + } + + pub fn capture(&self, event: Event) -> Result<(), Error> { + let inner_event = InnerEvent::new(event, self.options.api_key.clone()); + self.send_request::<_, _, serde_json::Value>(Method::POST, "/capture/", &inner_event)?; Ok(()) } pub fn capture_batch(&self, events: Vec) -> Result<(), Error> { - let inner_event = InnerEventBatch::new(events, self.options.api_key.clone()); - let _res = self - .http_client - .post(self.options.api_endpoint.clone()) - .header(CONTENT_TYPE, "application/json") - .body(serde_json::to_string(&inner_event).expect("unwrap here is safe")) - .send() - .map_err(|source| Error::Connection { source })?; + let inner_event_batch = InnerEventBatch::new(events, self.options.api_key.clone()); + self.send_request::<_, _, serde_json::Value>(Method::POST, "/batch/", &inner_event_batch)?; Ok(()) } } diff --git a/sync/src/client_options.rs b/sync/src/client_options.rs index e150ecb..b4019cc 100644 --- a/sync/src/client_options.rs +++ b/sync/src/client_options.rs @@ -2,7 +2,7 @@ use std::time::Duration; use crate::client::Client; -const API_ENDPOINT: &str = "https://app.posthog.com/"; +const API_ENDPOINT: &str = "https://app.posthog.com"; const TIMEOUT: Duration = Duration::from_millis(800); // This should be specified by the user pub struct ClientOptions { diff --git a/sync/src/error.rs b/sync/src/error.rs index ea418a6..74024e6 100644 --- a/sync/src/error.rs +++ b/sync/src/error.rs @@ -2,6 +2,10 @@ pub enum Error { #[error("{source}")] PostHogCore { source: posthog_core::error::Error }, - #[error("connection: {source}")] - Connection { source: reqwest::Error }, + #[error("send request: {source}")] + SendRequest { source: reqwest::Error }, + #[error("response status: {source}")] + ResponseStatus { source: reqwest::Error }, + #[error("decode response: {source}")] + DecodeResponse { source: reqwest::Error }, } diff --git a/sync/tests/basic.rs b/sync/tests/basic.rs index 8d1e418..6ca800b 100644 --- a/sync/tests/basic.rs +++ b/sync/tests/basic.rs @@ -1,9 +1,13 @@ use posthog_rs::Event; use std::collections::HashMap; +fn build_client() -> posthog_rs::Client { + posthog_rs::client(env!("POSTHOG_API_KEY")) +} + #[test] -fn get_client() { - let client = posthog_rs::client(env!("POSTHOG_API_KEY")); +fn capture() { + let client = build_client(); let mut child_map = HashMap::new(); child_map.insert("child_key1", "child_value1"); @@ -15,3 +19,24 @@ fn get_client() { client.capture(event).unwrap(); } + +#[test] +fn capture_batch() { + let client = build_client(); + + let events = (0..16) + .map(|_| { + let mut child_map = HashMap::new(); + child_map.insert("child_key1", "child_value1"); + + let mut event = Event::new("test", "1234"); + event.insert_prop("key1", "value1").unwrap(); + event.insert_prop("key2", vec!["a", "b"]).unwrap(); + event.insert_prop("key3", child_map).unwrap(); + + event + }) + .collect::>(); + + client.capture_batch(events).unwrap(); +} From e2667783f49d14e2cf74f49ca19e2b6663f02263 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 05:06:10 +0000 Subject: [PATCH 09/16] Update test event names to specify which test is running --- async/tests/basic.rs | 4 ++-- sync/tests/basic.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/async/tests/basic.rs b/async/tests/basic.rs index 2afca46..3a59288 100644 --- a/async/tests/basic.rs +++ b/async/tests/basic.rs @@ -12,7 +12,7 @@ async fn capture() { let mut child_map = HashMap::new(); child_map.insert("child_key1", "child_value1"); - let mut event = Event::new("test", "1234"); + let mut event = Event::new("test async capture", "1234"); event.insert_prop("key1", "value1").unwrap(); event.insert_prop("key2", vec!["a", "b"]).unwrap(); event.insert_prop("key3", child_map).unwrap(); @@ -29,7 +29,7 @@ async fn capture_batch() { let mut child_map = HashMap::new(); child_map.insert("child_key1", "child_value1"); - let mut event = Event::new("test", "1234"); + let mut event = Event::new("test async capture batch", "1234"); event.insert_prop("key1", "value1").unwrap(); event.insert_prop("key2", vec!["a", "b"]).unwrap(); event.insert_prop("key3", child_map).unwrap(); diff --git a/sync/tests/basic.rs b/sync/tests/basic.rs index 6ca800b..67a781b 100644 --- a/sync/tests/basic.rs +++ b/sync/tests/basic.rs @@ -12,7 +12,7 @@ fn capture() { let mut child_map = HashMap::new(); child_map.insert("child_key1", "child_value1"); - let mut event = Event::new("test", "1234"); + let mut event = Event::new("test sync capture", "1234"); event.insert_prop("key1", "value1").unwrap(); event.insert_prop("key2", vec!["a", "b"]).unwrap(); event.insert_prop("key3", child_map).unwrap(); @@ -29,7 +29,7 @@ fn capture_batch() { let mut child_map = HashMap::new(); child_map.insert("child_key1", "child_value1"); - let mut event = Event::new("test", "1234"); + let mut event = Event::new("test sync capture batch", "1234"); event.insert_prop("key1", "value1").unwrap(); event.insert_prop("key2", vec!["a", "b"]).unwrap(); event.insert_prop("key3", child_map).unwrap(); From ef4e80e57747ea7204794bce9a103bfeccefabf1 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Jan 2023 18:49:27 +0000 Subject: [PATCH 10/16] Flatten properties --- core/src/event.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/event.rs b/core/src/event.rs index dbde614..d21c1ca 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -39,6 +39,7 @@ pub struct Event { #[derive(Serialize, Debug, PartialEq, Eq)] pub struct Properties { distinct_id: String, + #[serde(flatten)] props: HashMap, } From ba1d04a3fcd21ce467b17a065cc8f767a9ee89f0 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sat, 23 Dec 2023 15:45:59 +1030 Subject: [PATCH 11/16] feat: group support --- async/src/client.rs | 13 ++++++++ async/src/lib.rs | 1 + async/tests/basic.rs | 14 +++++++- core/src/event.rs | 22 ++++++++++--- core/src/group_identify.rs | 67 ++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 1 + sync/src/client.rs | 12 +++++++ sync/src/lib.rs | 1 + sync/tests/basic.rs | 14 +++++++- 9 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 core/src/group_identify.rs diff --git a/async/src/client.rs b/async/src/client.rs index f4cdbee..724b2eb 100644 --- a/async/src/client.rs +++ b/async/src/client.rs @@ -1,4 +1,5 @@ use posthog_core::event::{Event, InnerEvent, InnerEventBatch}; +use posthog_core::group_identify::GroupIdentify; use reqwest::{Client as HttpClient, Method}; use serde::{de::DeserializeOwned, Serialize}; @@ -59,4 +60,16 @@ impl Client { .await?; Ok(()) } + + pub async fn group_identify(&self, identify: GroupIdentify) -> Result<(), Error> { + let inner_event = InnerEvent::new( + identify + .try_into() + .map_err(|source| Error::PostHogCore { source })?, + self.options.api_key.clone(), + ); + self.send_request::<_, _, serde_json::Value>(Method::POST, "/capture/", &inner_event) + .await?; + Ok(()) + } } diff --git a/async/src/lib.rs b/async/src/lib.rs index de4bf5f..fb45065 100644 --- a/async/src/lib.rs +++ b/async/src/lib.rs @@ -7,6 +7,7 @@ pub use client_options::ClientOptions; pub use error::Error; pub use posthog_core::event::{Event, Properties}; +pub use posthog_core::group_identify::GroupIdentify; pub fn client>(options: C) -> Client { options.into().build() diff --git a/async/tests/basic.rs b/async/tests/basic.rs index 3a59288..840ff51 100644 --- a/async/tests/basic.rs +++ b/async/tests/basic.rs @@ -1,4 +1,4 @@ -use async_posthog::Event; +use async_posthog::{Event, GroupIdentify}; use std::collections::HashMap; fn build_client() -> async_posthog::Client { @@ -17,6 +17,8 @@ async fn capture() { event.insert_prop("key2", vec!["a", "b"]).unwrap(); event.insert_prop("key3", child_map).unwrap(); + event.insert_group("company", "company_key"); + client.capture(event).await.unwrap(); } @@ -40,3 +42,13 @@ async fn capture_batch() { client.capture_batch(events).await.unwrap(); } + +#[tokio::test] +async fn group_identify() { + let client = build_client(); + + let mut event = GroupIdentify::new("organisation", "some_id"); + event.insert_prop("status", "active").unwrap(); + + client.group_identify(event).await.unwrap(); +} diff --git a/core/src/event.rs b/core/src/event.rs index d21c1ca..04a713b 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -31,16 +31,18 @@ impl InnerEventBatch { #[derive(Serialize, Debug, PartialEq, Eq)] pub struct Event { - event: String, - properties: Properties, - timestamp: Option, + pub(crate) event: String, + pub(crate) properties: Properties, + pub(crate) timestamp: Option, } #[derive(Serialize, Debug, PartialEq, Eq)] pub struct Properties { - distinct_id: String, + pub(crate) distinct_id: String, + #[serde(rename = "$groups", skip_serializing_if = "Option::is_none")] + pub(crate) groups: Option>, #[serde(flatten)] - props: HashMap, + pub(crate) props: HashMap, } impl Properties { @@ -48,6 +50,7 @@ impl Properties { Self { distinct_id: distinct_id.into(), props: Default::default(), + groups: None, } } } @@ -72,4 +75,13 @@ impl Event { let _ = self.properties.props.insert(key.into(), as_json); Ok(()) } + + pub fn insert_group, P: Into>( + &mut self, + group_type: K, + group_key: P, + ) -> () { + let groups = self.properties.groups.get_or_insert_with(HashMap::new); + groups.insert(group_type.into(), group_key.into()); + } } diff --git a/core/src/group_identify.rs b/core/src/group_identify.rs new file mode 100644 index 0000000..3065be4 --- /dev/null +++ b/core/src/group_identify.rs @@ -0,0 +1,67 @@ +use chrono::NaiveDateTime; +use serde::Serialize; +use std::collections::HashMap; + +use crate::{ + error::Error, + event::{Event, Properties}, +}; + +#[derive(Serialize, Debug, PartialEq, Eq)] +pub struct GroupIdentify { + group_type: String, + group_key: String, + group_properties: Option, + timestamp: Option, +} + +impl GroupIdentify { + pub fn new>(group_type: S, group_id: S) -> Self { + Self { + group_type: group_type.into(), + group_key: group_id.into(), + group_properties: None, + timestamp: None, + } + } + + pub fn insert_prop, P: Serialize>( + &mut self, + key: K, + prop: P, + ) -> Result<(), Error> { + let as_json = + serde_json::to_value(prop).map_err(|source| Error::Serialization { source })?; + let _ = self + .group_properties + .as_mut() + .unwrap() + .props + .insert(key.into(), as_json); + Ok(()) + } +} + +impl TryFrom for Event { + type Error = Error; + fn try_from(group_identify: GroupIdentify) -> Result { + let distinct_id = format!("{}_{}", group_identify.group_type, group_identify.group_key); + + let mut props: HashMap = HashMap::with_capacity(3); + props.insert("$group_type".into(), group_identify.group_type.into()); + props.insert("$group_key".into(), group_identify.group_key.into()); + if let Some(properties) = group_identify.group_properties { + props.insert("$group_set".into(), serde_json::to_value(properties.props)?); + } + + Ok(Self { + event: "$groupidentify".into(), + properties: Properties { + distinct_id, + props, + groups: None, + }, + timestamp: group_identify.timestamp, + }) + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 1610d23..9f5d4d2 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,2 +1,3 @@ pub mod error; pub mod event; +pub mod group_identify; diff --git a/sync/src/client.rs b/sync/src/client.rs index ed6e2bc..d3f81b5 100644 --- a/sync/src/client.rs +++ b/sync/src/client.rs @@ -1,4 +1,5 @@ use posthog_core::event::{Event, InnerEvent, InnerEventBatch}; +use posthog_core::group_identify::GroupIdentify; use reqwest::{blocking::Client as HttpClient, Method}; use serde::{de::DeserializeOwned, Serialize}; @@ -55,4 +56,15 @@ impl Client { self.send_request::<_, _, serde_json::Value>(Method::POST, "/batch/", &inner_event_batch)?; Ok(()) } + + pub fn group_identify(&self, identify: GroupIdentify) -> Result<(), Error> { + let inner_event = InnerEvent::new( + identify + .try_into() + .map_err(|source| Error::PostHogCore { source })?, + self.options.api_key.clone(), + ); + self.send_request::<_, _, serde_json::Value>(Method::POST, "/capture/", &inner_event)?; + Ok(()) + } } diff --git a/sync/src/lib.rs b/sync/src/lib.rs index de4bf5f..fb45065 100644 --- a/sync/src/lib.rs +++ b/sync/src/lib.rs @@ -7,6 +7,7 @@ pub use client_options::ClientOptions; pub use error::Error; pub use posthog_core::event::{Event, Properties}; +pub use posthog_core::group_identify::GroupIdentify; pub fn client>(options: C) -> Client { options.into().build() diff --git a/sync/tests/basic.rs b/sync/tests/basic.rs index 67a781b..8c57396 100644 --- a/sync/tests/basic.rs +++ b/sync/tests/basic.rs @@ -1,4 +1,4 @@ -use posthog_rs::Event; +use posthog_rs::{Event, GroupIdentify}; use std::collections::HashMap; fn build_client() -> posthog_rs::Client { @@ -17,6 +17,8 @@ fn capture() { event.insert_prop("key2", vec!["a", "b"]).unwrap(); event.insert_prop("key3", child_map).unwrap(); + event.insert_group("company", "company_key"); + client.capture(event).unwrap(); } @@ -40,3 +42,13 @@ fn capture_batch() { client.capture_batch(events).unwrap(); } + +#[test] +fn group_identify() { + let client = build_client(); + + let mut event = GroupIdentify::new("organisation", "some_id"); + event.insert_prop("status", "active").unwrap(); + + client.group_identify(event).unwrap(); +} From c4ca66892805a0f6d67be92e84945318027d03c8 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sat, 23 Dec 2023 15:53:43 +1030 Subject: [PATCH 12/16] docs: update README --- README.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a5feac0..ec18774 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,13 @@ Add `posthog-rs` to your `Cargo.toml`. ```toml [dependencies] -posthog_rs = "0.2.0" +posthog-rs = "0.2.0" ``` +## Events + +Capture events with `capture`. + ```rust let client = crate::client(env!("POSTHOG_API_KEY")); @@ -21,6 +25,36 @@ event.insert_prop("key1", "value1").unwrap(); event.insert_prop("key2", vec!["a", "b"]).unwrap(); client.capture(event).unwrap(); +``` + +## Groups + +[Group analytics](https://posthog.com/docs/product-analytics/group-analytics) are supported. + +### Identifying Groups + +Groups can be created with [`group_identify`](https://posthog.com/docs/product-analytics/group-analytics#how-to-create-groups). + +```rust +let client = crate::client(env!("POSTHOG_API_KEY")); + +let mut event = GroupIdentify::new("organisation", "some_id"); +event.insert_prop("status", "active").unwrap(); + +client.group_identify(event).unwrap(); ``` +### Associating Events with a Group + +```rust +let client = crate::client(env!("POSTHOG_API_KEY")); + +let mut event = Event::new("test", "1234"); + +// Optionally associate this event with a group (in this case, +// a "company" group type with key "company_id_123"). +event.insert_group("company", "company_id_123"); + +client.capture(event).unwrap(); +``` From ac647725e6d8c8ebbeac333427d8b1377d11ad14 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sat, 23 Dec 2023 16:03:55 +1030 Subject: [PATCH 13/16] . --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ec18774..b95fec2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Add `posthog-rs` to your `Cargo.toml`. ```toml [dependencies] -posthog-rs = "0.2.0" +posthog-rs = "0.2.0" # for sync client +async-posthog = "0.2.0" # for async client ``` ## Events From df0ac76e9b517045d5a82b983586853ba0f0a602 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sat, 23 Dec 2023 16:40:01 +1030 Subject: [PATCH 14/16] fix: add with_timestamp method --- core/src/event.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/src/event.rs b/core/src/event.rs index 04a713b..64252a1 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -64,6 +64,18 @@ impl Event { } } + pub fn with_timestamp>( + event: S, + distinct_id: S, + timestamp: NaiveDateTime, + ) -> Self { + Self { + event: event.into(), + properties: Properties::new(distinct_id), + timestamp: Some(timestamp), + } + } + /// Errors if `prop` fails to serialize pub fn insert_prop, P: Serialize>( &mut self, From c8cd61a897d6ac90ea951239f9997e021de8ca70 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sat, 23 Dec 2023 17:45:07 +1030 Subject: [PATCH 15/16] fix: add with_timestamp method to group_identify --- core/src/group_identify.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/core/src/group_identify.rs b/core/src/group_identify.rs index 3065be4..8271827 100644 --- a/core/src/group_identify.rs +++ b/core/src/group_identify.rs @@ -25,6 +25,19 @@ impl GroupIdentify { } } + pub fn with_timestamp>( + group_type: S, + group_id: S, + timestamp: NaiveDateTime, + ) -> Self { + Self { + group_type: group_type.into(), + group_key: group_id.into(), + group_properties: None, + timestamp: Some(timestamp), + } + } + pub fn insert_prop, P: Serialize>( &mut self, key: K, From d3046e2159bc4819789ebdaeaa85f87f00eb4661 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Tue, 26 Dec 2023 13:04:31 +1030 Subject: [PATCH 16/16] fix: group identify --- core/src/group_identify.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/core/src/group_identify.rs b/core/src/group_identify.rs index 8271827..06128d3 100644 --- a/core/src/group_identify.rs +++ b/core/src/group_identify.rs @@ -45,12 +45,16 @@ impl GroupIdentify { ) -> Result<(), Error> { let as_json = serde_json::to_value(prop).map_err(|source| Error::Serialization { source })?; - let _ = self - .group_properties - .as_mut() - .unwrap() - .props - .insert(key.into(), as_json); + let key = key.into(); + + let group_properties = self.group_properties.get_or_insert_with(|| Properties { + distinct_id: self.group_key.clone(), + props: HashMap::new(), + groups: None, + }); + + group_properties.props.insert(key, as_json); + Ok(()) } }