From 96d30e62eab762a6ede8511f7da35c31e270b98d Mon Sep 17 00:00:00 2001 From: Mark Biesheuvel Date: Wed, 7 Aug 2024 13:10:37 +0200 Subject: [PATCH] Refactor event dispatchers --- examples/results-demo/src/main.rs | 8 -- optimizely/Cargo.toml | 16 +-- optimizely/src/client/initialization.rs | 9 +- optimizely/src/client/user.rs | 103 ++++++++------- optimizely/src/conversion.rs | 47 +++++++ optimizely/src/datafile.rs | 8 +- optimizely/src/datafile/event.rs | 1 - optimizely/src/decision.rs | 43 ++++-- optimizely/src/event_api.rs | 2 - .../src/event_api/batched_event_dispatcher.rs | 112 ++++++++++------ .../batched_payload.rs | 87 ------------- optimizely/src/event_api/client.rs | 9 +- optimizely/src/event_api/event.rs | 122 ------------------ optimizely/src/event_api/request/payload.rs | 98 ++++++++------ optimizely/src/event_api/request/snapshot.rs | 31 +++-- optimizely/src/event_api/request/visitor.rs | 14 +- .../src/event_api/simple_event_dispatcher.rs | 60 ++++----- .../src/event_api/trait_event_dispatcher.rs | 52 +------- optimizely/src/lib.rs | 3 + optimizely/tests/common/mod.rs | 38 ++++-- optimizely/tests/decisions.rs | 8 +- optimizely/tests/user_context.rs | 2 +- 22 files changed, 368 insertions(+), 505 deletions(-) create mode 100644 optimizely/src/conversion.rs delete mode 100644 optimizely/src/event_api/batched_event_dispatcher/batched_payload.rs delete mode 100644 optimizely/src/event_api/event.rs diff --git a/examples/results-demo/src/main.rs b/examples/results-demo/src/main.rs index 898f27a..95e98fa 100644 --- a/examples/results-demo/src/main.rs +++ b/examples/results-demo/src/main.rs @@ -16,14 +16,6 @@ const FLAG_KEY: &str = "results_demo"; const ADD_TO_CART_EVENT_KEY: &str = "add_to_cart"; const PURCHASE_EVENT_KEY: &str = "purchase"; -enum ProductCategory { - Games, - Controllers, - Headsets, - Mouses, - Keyboards, -} - /// Whether a random event does or doesn't happen fn random_event_does_happen(chance: f32) -> bool { random::() < chance diff --git a/optimizely/Cargo.toml b/optimizely/Cargo.toml index 8289d08..3c3f505 100644 --- a/optimizely/Cargo.toml +++ b/optimizely/Cargo.toml @@ -1,25 +1,25 @@ [package] name = "optimizely" -version = "0.2.0" +version = "0.3.0" edition = "2021" [dependencies] -serde_json = "1.0.107" +serde_json = "1.0" thiserror = "1.0" -error-stack = "0.3.1" -fasthash = "0.4.0" -log = "0.4.17" +error-stack = "0.5" +fasthash = "0.4" +log = "0.4" [dependencies.serde] -version = "1.0.188" +version = "1.0" features = ["derive"] [dependencies.ureq] -version = "2.5.0" +version = "2.10" optional = true [dependencies.uuid] -version = "1.3.0" +version = "1.10" features = ["v4", "fast-rng"] [features] diff --git a/optimizely/src/client/initialization.rs b/optimizely/src/client/initialization.rs index 20995f7..e79e57e 100644 --- a/optimizely/src/client/initialization.rs +++ b/optimizely/src/client/initialization.rs @@ -1,5 +1,5 @@ // External imports -use error_stack::{IntoReport, Result, ResultExt}; +use error_stack::{Result, ResultExt}; use std::fs::File; use std::io::Read; @@ -44,13 +44,11 @@ impl Client { // TODO: implement polling mechanism let response = ureq::get(&url) .call() - .into_report() .change_context(ClientError::FailedRequest)?; // Get response body let content = response .into_string() - .into_report() .change_context(ClientError::FailedResponse)?; // Use response to build Client @@ -63,13 +61,10 @@ impl Client { let mut content = String::new(); // Open file - let mut file = File::open(file_path) - .into_report() - .change_context(ClientError::FailedFileOpen)?; + let mut file = File::open(file_path).change_context(ClientError::FailedFileOpen)?; // Read file content into String file.read_to_string(&mut content) - .into_report() .change_context(ClientError::FailedFileRead)?; // Use file content to build Client diff --git a/optimizely/src/client/user.rs b/optimizely/src/client/user.rs index be2d457..732b9c0 100644 --- a/optimizely/src/client/user.rs +++ b/optimizely/src/client/user.rs @@ -3,12 +3,10 @@ use fasthash::murmur3::hash32_with_seed as murmur3_hash; use std::collections::HashMap; // Imports from crate +use crate::conversion::Conversion; use crate::datafile::{Experiment, FeatureFlag, Variation}; use crate::decision::{DecideOptions, Decision}; -#[cfg(feature = "online")] -use crate::event_api; - // Imports from super use super::Client; @@ -75,6 +73,11 @@ impl UserContext<'_> { self.attributes.insert(key, value); } + /// Get the client instance + pub fn client(&self) -> &Client { + self.client + } + /// Get the id of a user pub fn user_id(&self) -> &str { self.user_id @@ -106,21 +109,18 @@ impl UserContext<'_> { pub fn track_event_with_properties_and_tags( &self, event_key: &str, properties: HashMap, tags: HashMap, ) { + // Find the event key in the datafile match self.client.datafile().event(event_key) { Some(event) => { log::debug!("Logging conversion event"); - // Send out a decision event as a side effect - let user_id = self.user_id(); - let account_id = self.client.datafile().account_id(); - let event_id = event.id(); - - // Create event_api::Event to send to dispatcher - let conversion_event = - event_api::Event::conversion(account_id, user_id, event_id, event_key, properties, tags); + // Create conversion to send to dispatcher + let conversion = Conversion::new(event_key, event.id(), properties, tags); // Ignore result of the send_decision function - self.client.event_dispatcher().send_event(conversion_event); + self.client + .event_dispatcher() + .send_conversion_event(self, conversion); } None => { log::warn!("Event key does not exist in datafile"); @@ -129,13 +129,13 @@ impl UserContext<'_> { } /// Decide which variation to show to a user - pub fn decide<'b>(&self, flag_key: &'b str) -> Decision<'b> { + pub fn decide(&self, flag_key: &str) -> Decision { let options = DecideOptions::default(); self.decide_with_options(flag_key, &options) } /// Decide which variation to show to a user - pub fn decide_with_options<'b>(&self, flag_key: &'b str, options: &DecideOptions) -> Decision<'b> { + pub fn decide_with_options(&self, flag_key: &str, options: &DecideOptions) -> Decision { // Retrieve Flag object let flag = match self.client.datafile().flag(flag_key) { Some(flag) => flag, @@ -147,38 +147,58 @@ impl UserContext<'_> { }; // Only send decision events if the disable_decision_event option is false - let send_decision = !options.disable_decision_event; + let mut send_decision = !options.disable_decision_event; // Get the selected variation for the given flag - match self.decide_variation_for_flag(flag, send_decision) { - Some(variation) => { + let decision = match self.decide_variation_for_flag(flag, &mut send_decision) { + Some((experiment, variation)) => { // Unpack the variation and create Decision struct - Decision::new(flag_key, variation.is_feature_enabled(), variation.key()) + Decision::new( + flag_key, + experiment.campaign_id(), + experiment.id(), + variation.id(), + variation.key(), + variation.is_feature_enabled(), + ) } None => { // No experiment or rollout found, or user does not qualify for any Decision::off(flag_key) } + }; + + #[cfg(feature = "online")] + if send_decision { + self.client.event_dispatcher().send_decision_event(&self, decision.clone()); } + + // Return + decision } - fn decide_variation_for_flag<'a>(&'a self, flag: &'a FeatureFlag, send_decision: bool) -> Option<&Variation> { + fn decide_variation_for_flag(&self, flag: &FeatureFlag, send_decision: &mut bool) -> Option<(&Experiment, &Variation)> { // Find first Experiment for which this user qualifies let result = flag.experiments_ids().iter().find_map(|experiment_id| { let experiment = self.client.datafile().experiment(experiment_id); match experiment { - Some(experiment) => self.decide_variation_for_experiment(experiment, send_decision), + Some(experiment) => self.decide_variation_for_experiment(experiment), None => None, } }); match result { Some(_) => { - // A matching A/B test was found, send out any decisions + // Send out a decision event for an A/B Test + *send_decision &= true; + result } None => { + // Do not send any decision for a Rollout (Targeted Delivery) + *send_decision = false; + // No direct experiment found, let's look at the Rollout let rollout = self.client.datafile().rollout(flag.rollout_id()).unwrap(); // TODO: remove unwrap @@ -186,14 +206,14 @@ impl UserContext<'_> { rollout .experiments() .iter() - .find_map(|experiment| self.decide_variation_for_experiment(experiment, false)) + .find_map(|experiment| self.decide_variation_for_experiment(experiment)) } } } fn decide_variation_for_experiment<'a>( - &'a self, experiment: &'a Experiment, send_decision: bool, - ) -> Option<&Variation> { + &'a self, experiment: &'a Experiment + ) -> Option<(&'a Experiment, &'a Variation)> { // Use references for the ids let user_id = self.user_id(); let experiment_id = experiment.id(); @@ -208,32 +228,15 @@ impl UserContext<'_> { // Bring the hash into a range of 0 to 10_000 let bucket_value = ((hash_value as f64) / (u32::MAX as f64) * MAX_OF_RANGE) as u64; - // Get the variation according to the traffic allocation - let result = experiment.traffic_allocation().variation(bucket_value); - - match result { - Some(variation_id) => { - if send_decision { - #[cfg(feature = "online")] - { - // Send out a decision event as a side effect - let account_id = self.client.datafile().account_id(); - let campaign_id = experiment.campaign_id(); - - // Create event_api::Event to send to dispatcher - let decision_event = - event_api::Event::decision(account_id, user_id, campaign_id, experiment_id, variation_id); - - // Ignore result of the send_decision function - self.client.event_dispatcher().send_event(decision_event); - } - } - - // Find the variation belonging to this variation ID - experiment.variation(variation_id) - } - None => None, - } + // Get the variation ID according to the traffic allocation + experiment.traffic_allocation() + .variation(bucket_value) + // Map it to a Variation struct + .map(|variation_id| experiment.variation(variation_id)) + .flatten() + // Combine it with the experiment + .map(|variation| Some((experiment, variation))) + .flatten() } } diff --git a/optimizely/src/conversion.rs b/optimizely/src/conversion.rs new file mode 100644 index 0000000..7a7ec86 --- /dev/null +++ b/optimizely/src/conversion.rs @@ -0,0 +1,47 @@ +//! A conversion event + +use std::collections::HashMap; + +/// A conversion event +#[derive(Debug)] +pub struct Conversion { + event_key: String, + event_id: String, + properties: HashMap, + tags: HashMap, +} + +impl Conversion { + pub(crate) fn new>( + event_key: T, event_id: T, properties: HashMap, tags: HashMap, + ) -> Conversion { + Conversion { + event_key: event_key.into(), + event_id: event_id.into(), + properties, + tags, + } + } +} + +impl Conversion { + /// Get key + pub fn event_key(&self) -> &str { + &self.event_key + } + + /// Get id + pub fn event_id(&self) -> &str { + &self.event_id + } + + /// Get properties + pub fn properties(&self) -> &HashMap { + &self.properties + } + + /// Get tags + pub fn tags(&self) -> &HashMap { + &self.tags + } +} diff --git a/optimizely/src/datafile.rs b/optimizely/src/datafile.rs index 346ff89..7005c7c 100644 --- a/optimizely/src/datafile.rs +++ b/optimizely/src/datafile.rs @@ -1,12 +1,12 @@ //! Parsing the Optimizely datafile // External imports -use error_stack::{IntoReport, Result, ResultExt}; +use error_stack::{Result, ResultExt}; // Relative imports of sub modules use environment::Environment; pub use error::DatafileError; -use event::Event; +pub(crate) use event::Event; pub(crate) use experiment::Experiment; pub(crate) use feature_flag::FeatureFlag; use rollout::Rollout; @@ -39,9 +39,7 @@ impl Datafile { /// Construct a new Datafile from a string containing a JSON document pub fn build(content: &str) -> Result { // Parse the JSON content via Serde into Rust structs - let environment: Environment = serde_json::from_str(content) - .into_report() - .change_context(DatafileError::InvalidJson)?; + let environment: Environment = serde_json::from_str(content).change_context(DatafileError::InvalidJson)?; Ok(Datafile(environment)) } diff --git a/optimizely/src/datafile/event.rs b/optimizely/src/datafile/event.rs index d2b3289..d4b3b97 100644 --- a/optimizely/src/datafile/event.rs +++ b/optimizely/src/datafile/event.rs @@ -22,7 +22,6 @@ impl Event { } /// Getter for `id` field - #[allow(dead_code)] pub fn id(&self) -> &str { &self.id } diff --git a/optimizely/src/decision.rs b/optimizely/src/decision.rs index 1f980ff..122f7de 100644 --- a/optimizely/src/decision.rs +++ b/optimizely/src/decision.rs @@ -4,30 +4,36 @@ pub use decide_options::DecideOptions; mod decide_options; -/// Decision for a specfic user and feature flag -#[derive(Debug)] -pub struct Decision<'a> { - flag_key: &'a str, - enabled: bool, +/// Decision for a specific user and feature flag +#[derive(Debug, Clone)] +pub struct Decision { + flag_key: String, + campaign_id: String, + experiment_id: String, + variation_id: String, variation_key: String, + enabled: bool, } -impl Decision<'_> { - pub(crate) fn new>(flag_key: &str, enabled: bool, variation_key: T) -> Decision { +impl Decision { + pub(crate) fn new>(flag_key: T, campaign_id: T, experiment_id: T, variation_id: T, variation_key: T, enabled: bool) -> Decision { Decision { - flag_key, - enabled, + flag_key: flag_key.into(), + campaign_id: campaign_id.into(), + experiment_id: experiment_id.into(), + variation_id: variation_id.into(), variation_key: variation_key.into(), + enabled, } } pub(crate) fn off(flag_key: &str) -> Decision { - Decision::new(flag_key, false, "off") + Decision::new(flag_key, "-1", "-1", "-1", "off", false) } /// Get the flag key for which this decision was made pub fn flag_key(&self) -> &str { - self.flag_key + &self.flag_key } /// Get whether the flag should be enabled or disable @@ -39,4 +45,19 @@ impl Decision<'_> { pub fn variation_key(&self) -> &str { &self.variation_key } + + /// Get the campaign ID + pub fn campaign_id(&self) -> &str { + &self.campaign_id + } + + /// Get the experiment ID + pub fn experiment_id(&self) -> &str { + &self.experiment_id + } + + /// Get the variation ID that was decided + pub fn variation_id(&self) -> &str { + &self.variation_id + } } diff --git a/optimizely/src/event_api.rs b/optimizely/src/event_api.rs index 1716fb9..634b3c1 100644 --- a/optimizely/src/event_api.rs +++ b/optimizely/src/event_api.rs @@ -4,14 +4,12 @@ pub use batched_event_dispatcher::BatchedEventDispatcher; pub use client::EventApiClient; pub use error::EventApiError; -pub use event::Event; pub use simple_event_dispatcher::SimpleEventDispatcher; pub use trait_event_dispatcher::EventDispatcher; mod batched_event_dispatcher; mod client; mod error; -mod event; pub mod request; mod simple_event_dispatcher; mod trait_event_dispatcher; diff --git a/optimizely/src/event_api/batched_event_dispatcher.rs b/optimizely/src/event_api/batched_event_dispatcher.rs index 7310dad..e6a993b 100644 --- a/optimizely/src/event_api/batched_event_dispatcher.rs +++ b/optimizely/src/event_api/batched_event_dispatcher.rs @@ -3,60 +3,67 @@ use std::sync::mpsc; use std::thread; // Imports from super -use super::{Event, EventDispatcher}; +use super::{request::Payload, EventDispatcher}; -// Relative imports of sub modules -use batched_payload::BatchedPayload; +// Imports from crate +use crate::{client::UserContext, Conversion, Decision}; -mod batched_payload; +// Structure used to send message between threads +struct ThreadMessage { + account_id: String, + user_id: String, + event: EventEnum, +} +enum EventEnum { + Conversion(Conversion), + Decision(Decision), +} -/// Implementation of the EventDisptacher trait that collects multiple events before sending them -/// -/// ``` -/// use optimizely::event_api::{BatchedEventDispatcher, Event, EventDispatcher}; -/// -/// // Create some example IDs -/// let account_id = "21537940595"; -/// let user_ids = vec!["user0", "user1", "user2"]; -/// let campaign_id = "9300000133039"; -/// let experiment_id = "9300000169122"; -/// let variation_ids = vec!["87757", "87757", "87755"]; -/// -/// // Create events from above IDs -/// let events = user_ids.iter() -/// .zip(variation_ids.iter()) -/// .map(|(user_id, variation_id)| { -/// Event::decision(account_id, user_id, campaign_id, experiment_id, variation_id) -/// }); -/// -/// // Create batched event disptacher -/// let dispatcher = BatchedEventDispatcher::default(); -/// -/// // Send all events -/// for event in events { -/// dispatcher.send_event(event); -/// } +// Upper limit to number of events in a batch +const DEFAULT_BATCH_THRESHOLD: usize = 10; + +/// Implementation of the EventDispatcher trait that collects multiple events before sending them /// -/// // Note that only one request will be sent to the Event API -/// ``` +/// TODO: add example usage in SDK /// /// Inspiration from [Spawn threads and join in destructor](https://users.rust-lang.org/t/spawn-threads-and-join-in-destructor/1613/9) pub struct BatchedEventDispatcher { thread_handle: Option>, - transmitter: Option>, + transmitter: Option>, } impl Default for BatchedEventDispatcher { /// Constructor for a new batched event dispatcher fn default() -> BatchedEventDispatcher { - let (transmitter, receiver) = mpsc::channel(); + let (transmitter, receiver) = mpsc::channel::(); + // Receiver logic in separate thread let thread_handle = thread::spawn(move || { - let mut batched_payload = BatchedPayload::new(); + let mut payload_option = Option::None; + + // Keep receiving new messages from the main thread + for message in receiver.iter() { + // Deconstruct the message + let ThreadMessage { account_id, user_id, event } = message; - // Keep receiving new message from the main thread - for event in receiver.iter() { - batched_payload.add_event(event); + // Use existing payload or create new one + let payload = payload_option.get_or_insert_with(|| Payload::new(account_id)); + + // the corresponding event to the payload + match event { + EventEnum::Conversion(conversion) => { + payload.add_conversion_event(&user_id, &conversion); + }, + EventEnum::Decision(decision) => { + payload.add_decision_event(&user_id, &decision); + }, + } + } + + // Send payload if reached the batch threshold + if let Some(payload) = payload_option.take_if(|payload| payload.size() >= DEFAULT_BATCH_THRESHOLD) { + log::debug!("Reached DEFAULT_BATCH_THRESHOLD"); + payload.send(); } }); @@ -86,10 +93,31 @@ impl Drop for BatchedEventDispatcher { } impl EventDispatcher for BatchedEventDispatcher { - fn send_event(&self, event: Event) { - // Send event to thread + fn send_conversion_event(&self, user_context: &UserContext, conversion: Conversion) { + self.transmit(user_context, EventEnum::Conversion(conversion)) + } + + fn send_decision_event(&self, user_context: &UserContext, decision: Decision) { + self.transmit(user_context, EventEnum::Decision(decision)) + } +} + +impl BatchedEventDispatcher { + fn transmit(&self, user_context: &UserContext, event: EventEnum) { + // Create a String so the value can be owned by the other thread. + let account_id = user_context.client().datafile().account_id().into(); + let user_id = user_context.user_id().into(); + + // Build message + let message = ThreadMessage { + account_id, + user_id, + event, + }; + + // Send message to thread match &self.transmitter { - Some(tx) => match tx.send(event) { + Some(tx) => match tx.send(message) { Ok(_) => { log::debug!("Successfully sent message to thread"); } @@ -102,4 +130,4 @@ impl EventDispatcher for BatchedEventDispatcher { } } } -} +} \ No newline at end of file diff --git a/optimizely/src/event_api/batched_event_dispatcher/batched_payload.rs b/optimizely/src/event_api/batched_event_dispatcher/batched_payload.rs deleted file mode 100644 index 3d11005..0000000 --- a/optimizely/src/event_api/batched_event_dispatcher/batched_payload.rs +++ /dev/null @@ -1,87 +0,0 @@ -// Imports from crate -use super::super::{request::Payload, Event, EventApiClient}; - -// Upper limit to number of events in a batch -const DEFAULT_BATCH_THRESHOLD: u16 = 10; - -pub(super) struct BatchedPayload<'a> { - counter: u16, - payload_option: Option>, -} - -impl BatchedPayload<'_> { - pub(super) fn new() -> BatchedPayload<'static> { - let payload_option: Option = None; - let counter = 0; - - BatchedPayload { - counter, - payload_option, - } - } - - pub(super) fn add_event(&mut self, event: Event) { - // Add to the existing payload or create a new one - match self.payload_option.as_mut() { - None => { - // Create new payload - let mut payload = Payload::new(event.account_id()); - - // Add decision - payload.add_event(event); - - // Store for next iteration - self.payload_option = Some(payload); - } - Some(payload) => { - // Add decision - payload.add_event(event); - } - }; - - // Increment counter - self.counter += 1; - - if self.counter >= DEFAULT_BATCH_THRESHOLD { - log::debug!("Reached DEFAULT_BATCH_THRESHOLD"); - self.send(); - } - } - - fn send(&mut self) { - // Take ownership of payload and leave behind None (for next iteration) - match self.payload_option.take() { - Some(payload) => { - // Sending payload - log::debug!("Sending log payload to Event API"); - - // Send payload to endpoint - match EventApiClient::send(payload) { - Ok(_) => { - log::info!("Successfull request to Event API"); - } - Err(report) => { - log::error!("Failed request to Event API"); - log::error!("\n{report:?}"); - } - } - - // Reset counter - self.counter = 0; - } - None => { - // Nothing to send - log::debug!("No log payload to send"); - } - } - } -} - -impl Drop for BatchedPayload<'_> { - fn drop(&mut self) { - log::debug!("Dropping BatchedPayload"); - - // If the BatchedLogPayload is dropped, send one last payload - self.send() - } -} diff --git a/optimizely/src/event_api/client.rs b/optimizely/src/event_api/client.rs index a88f5c7..01571a7 100644 --- a/optimizely/src/event_api/client.rs +++ b/optimizely/src/event_api/client.rs @@ -1,5 +1,5 @@ // External imports -use error_stack::{IntoReport, Result, ResultExt}; +use error_stack::{Result, ResultExt}; // Imports from super use super::{request::Payload, EventApiError}; @@ -14,17 +14,14 @@ pub struct EventApiClient {} impl EventApiClient { /// Serialize the payload to JSON and send to Event API - pub fn send(payload: Payload) -> Result<(), EventApiError> { + pub fn send(payload: &Payload) -> Result<(), EventApiError> { // Convert to JSON document and dump as String - let body = serde_json::to_string(&payload) - .into_report() - .change_context(EventApiError::FailedSerialize)?; + let body = serde_json::to_string(payload).change_context(EventApiError::FailedSerialize)?; // Make POST request ureq::post(ENDPOINT_URL) .set(CONTENT_TYPE_KEY, CONTENT_TYPE_VALUE) .send_string(&body) - .into_report() .change_context(EventApiError::FailedRequest)?; Ok(()) diff --git a/optimizely/src/event_api/event.rs b/optimizely/src/event_api/event.rs deleted file mode 100644 index 09ad017..0000000 --- a/optimizely/src/event_api/event.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::collections::HashMap; - -/// Representation of the events which can be *dispatched* to Optimizely Event API. -/// -/// An event can either be a decision or conversion. -/// -/// ``` -/// use optimizely::event_api::Event; -/// use std::collections::HashMap; -/// -/// // Create some example IDs -/// let account_id = "21537940595"; -/// let user_id = "user0"; -/// let campaign_id = "9300000133039"; -/// let experiment_id = "9300000169122"; -/// let variation_id = "87757"; -/// let event_id = "22305150298"; -/// let event_key = "purchase"; -/// -/// let properties = HashMap::default(); -/// let tags = HashMap::default(); -/// -/// // Create two events from above IDs -/// let decision = Event::decision( -/// account_id, -/// user_id, -/// campaign_id, -/// experiment_id, -/// variation_id -/// ); -/// let conversion = Event::conversion( -/// account_id, -/// user_id, -/// event_id, -/// event_key, -/// properties, -/// tags, -/// ); -/// -/// // Assertions -/// assert_eq!(decision.account_id(), account_id); -/// assert_eq!(conversion.account_id(), account_id); -/// ``` -#[allow(dead_code)] -#[derive(Debug)] -pub enum Event { - /// An event that indicates a user being bucketed into an experiment - Decision { - #[doc(hidden)] - account_id: String, - #[doc(hidden)] - user_id: String, - #[doc(hidden)] - campaign_id: String, - #[doc(hidden)] - experiment_id: String, - #[doc(hidden)] - variation_id: String, - }, - - /// An event that indicates a user interacting with the application - Conversion { - #[doc(hidden)] - account_id: String, - #[doc(hidden)] - user_id: String, - #[doc(hidden)] - event_id: String, - #[doc(hidden)] - event_key: String, - #[doc(hidden)] - properties: HashMap, - #[doc(hidden)] - tags: HashMap, - }, -} - -impl Event { - /// Constructor for a new decision event - pub fn decision>( - account_id: T, user_id: T, campaign_id: T, experiment_id: T, variation_id: T, - ) -> Event { - Event::Decision { - account_id: account_id.into(), - user_id: user_id.into(), - campaign_id: campaign_id.into(), - experiment_id: experiment_id.into(), - variation_id: variation_id.into(), - } - } - - /// Constructor for a new decision event - pub fn conversion>( - account_id: T, user_id: T, event_id: T, event_key: T, properties: HashMap, - tags: HashMap, - ) -> Event { - Event::Conversion { - account_id: account_id.into(), - user_id: user_id.into(), - event_id: event_id.into(), - event_key: event_key.into(), - properties: properties, - tags: tags, - } - } - - /// Getter for the account_id field that exists for both `Event::Decision` and `Event::Conversion` - pub fn account_id(&self) -> &str { - match self { - Event::Decision { account_id, .. } => account_id, - Event::Conversion { account_id, .. } => account_id, - } - } - - /// Getter for the user_id field that exists for both `Event::Decision` and `Event::Conversion` - pub fn user_id(&self) -> &str { - match self { - Event::Decision { user_id, .. } => user_id, - Event::Conversion { user_id, .. } => user_id, - } - } -} diff --git a/optimizely/src/event_api/request/payload.rs b/optimizely/src/event_api/request/payload.rs index 9d8373f..d0154c7 100644 --- a/optimizely/src/event_api/request/payload.rs +++ b/optimizely/src/event_api/request/payload.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; // Imports from super use super::Visitor; -use crate::event_api::Event; +use crate::{event_api::EventApiClient, Conversion, Decision}; // Information regarding the SDK client const CLIENT_NAME: &str = "rust-sdk"; @@ -37,55 +37,75 @@ impl Payload<'_> { } } - /// Add a decision/conversion event to the payload - pub fn add_event(&mut self, event: Event) { - if event.account_id() != self.account_id { - // TODO: return a Result instead - panic!("Trying to add event from other account"); - } + /// Return the number of visitors in the payload + pub fn size(&self) -> usize { + self.visitors.len() + } + + /// Add a conversion event for a specific visitor to the payload + pub fn add_conversion_event>(&mut self, user_id: T, conversion: &Conversion) { + log::debug!("Adding conversion event to payload"); + // TODO: look up visitor ID in existing list + + // Create new request::Visitor + let mut visitor = Visitor::new(user_id); + + // Add custom event + visitor.add_event(conversion); + + // Add to the list + self.visitors.push(visitor); + } + /// Add a decision event for a specific visitor to the payload + pub fn add_decision_event>(&mut self, user_id: T, decision: &Decision) { + log::debug!("Adding decision event to payload"); // TODO: look up visitor ID in existing list - // Retrieve existing visitor or insert new one - let mut visitor = Visitor::new(event.user_id()); + // Create new request::Visitor + let mut visitor = Visitor::new(user_id); - match event { - Event::Decision { - campaign_id, - experiment_id, - variation_id, - .. - } => { - log::debug!("Adding decision event to log payload"); + // Use campaign_id as entity_id + let entity_id = decision.campaign_id(); - // Use a copy of campaign_id as entity_id - let entity_id = campaign_id.clone(); + // Add decision to visitor + visitor.add_decision(decision); - // Add decision to visitor - visitor.add_decision(campaign_id, experiment_id, variation_id); + // Campaign activated event does not have tags or properties + let properties = HashMap::default(); + let tags = HashMap::default(); - // Campaign activated event does not have tags or properties - let properties = HashMap::default(); - let tags = HashMap::default(); + // Add campaign_activated event + let conversion = Conversion::new(ACTIVATE_EVENT_KEY, entity_id, properties, tags); + visitor.add_event(&conversion); - // Add campaign_activated event - visitor.add_event(entity_id, String::from(ACTIVATE_EVENT_KEY), properties, tags); + // Add to the list + self.visitors.push(visitor); + } + + /// Send entire payload + pub fn send(&self) { + // Sending payload + log::debug!("Sending request to Event API"); + + // Send payload to endpoint + match EventApiClient::send(self) { + Ok(_) => { + log::info!("Successfully sent request to Event API"); } - Event::Conversion { - event_id, - event_key, - properties, - tags, - .. - } => { - log::debug!("Adding conversion event to log payload"); - - // Add custom event - visitor.add_event(event_id, event_key, properties, tags); + Err(report) => { + log::error!("Failed to send request to Event API"); + log::error!("\n{report:?}"); } } + } +} - // Add to the list - self.visitors.push(visitor); +impl Drop for Payload<'_> { + fn drop(&mut self) { + log::debug!("Dropping Payload"); + + // If the Payload is dropped, make one last request to the Event API + self.send() } } diff --git a/optimizely/src/event_api/request/snapshot.rs b/optimizely/src/event_api/request/snapshot.rs index c1036c1..dc6503f 100644 --- a/optimizely/src/event_api/request/snapshot.rs +++ b/optimizely/src/event_api/request/snapshot.rs @@ -1,14 +1,15 @@ // External imports use serde::Serialize; -use std::collections::HashMap; + +use crate::{Conversion as CrateConversion, Decision as CrateDecision}; // Imports from super -use super::{Decision, Event}; +use super::{Decision as PayloadDecision, Event as PayloadEvent}; #[derive(Serialize, Default)] pub struct Snapshot { - decisions: Vec, - events: Vec, + decisions: Vec, + events: Vec, } impl Snapshot { @@ -16,16 +17,24 @@ impl Snapshot { Snapshot::default() } - pub fn add_decision(&mut self, campaign_id: String, experiment_id: String, variation_id: String) { - let decision = Decision::new(campaign_id, experiment_id, variation_id); + pub fn add_decision(&mut self, decision: &CrateDecision) { + // TODO: impl From trait + let decision = PayloadDecision::new( + decision.campaign_id().into(), + decision.experiment_id().into(), + decision.variation_id().into(), + ); self.decisions.push(decision); } - pub fn add_event( - &mut self, entity_id: String, event_key: String, properties: HashMap, - tags: HashMap, - ) { - let event = Event::new(entity_id, event_key, properties, tags); + pub fn add_event(&mut self, conversion: &CrateConversion) { + // TODO: impl From trait + let event = PayloadEvent::new( + conversion.event_id().into(), + conversion.event_key().into(), + conversion.properties().clone(), + conversion.tags().clone(), + ); self.events.push(event); } } diff --git a/optimizely/src/event_api/request/visitor.rs b/optimizely/src/event_api/request/visitor.rs index 496676a..d94ce7e 100644 --- a/optimizely/src/event_api/request/visitor.rs +++ b/optimizely/src/event_api/request/visitor.rs @@ -1,6 +1,7 @@ // External imports use serde::Serialize; -use std::collections::HashMap; + +use crate::{Conversion, Decision}; // Imports from super use super::Snapshot; @@ -20,14 +21,11 @@ impl Visitor { } } - pub fn add_decision(&mut self, campaign_id: String, experiment_id: String, variation_id: String) { - self.snapshots[0].add_decision(campaign_id, experiment_id, variation_id); + pub fn add_decision(&mut self, decision: &Decision) { + self.snapshots[0].add_decision(decision); } - pub fn add_event( - &mut self, entity_id: String, event_key: String, properties: HashMap, - tags: HashMap, - ) { - self.snapshots[0].add_event(entity_id, event_key, properties, tags); + pub fn add_event(&mut self, conversion: &Conversion) { + self.snapshots[0].add_event(conversion); } } diff --git a/optimizely/src/event_api/simple_event_dispatcher.rs b/optimizely/src/event_api/simple_event_dispatcher.rs index d8ac7d4..f5bfee1 100644 --- a/optimizely/src/event_api/simple_event_dispatcher.rs +++ b/optimizely/src/event_api/simple_event_dispatcher.rs @@ -1,27 +1,10 @@ // Imports from super -use super::{request::Payload, Event, EventApiClient, EventDispatcher}; +use super::{request::Payload, EventDispatcher}; +use crate::{client::UserContext, Conversion, Decision}; -/// Implementation of the EventDisptacher trait that makes an HTTP request for every event +/// Implementation of the EventDispatcher trait that makes an HTTP request for every event /// -/// ``` -/// use optimizely::event_api::{Event, EventDispatcher, SimpleEventDispatcher}; -/// -/// // Create some example IDs -/// let account_id = "21537940595"; -/// let user_id = "user0"; -/// let campaign_id = "9300000133039"; -/// let experiment_id = "9300000169122"; -/// let variation_id = "87757"; -/// -/// // Create new event from above IDs -/// let event = Event::decision(account_id, user_id, campaign_id, experiment_id, variation_id); -/// -/// // Create simple event disptacher -/// let dispatcher = SimpleEventDispatcher::default(); -/// -/// // Send single event -/// dispatcher.send_event(event); -/// ``` +/// TODO: add example usage in SDK pub struct SimpleEventDispatcher {} impl Default for SimpleEventDispatcher { @@ -32,24 +15,29 @@ impl Default for SimpleEventDispatcher { } impl EventDispatcher for SimpleEventDispatcher { - fn send_event(&self, event: Event) { - log::debug!("Sending log payload to Event API"); + fn send_conversion_event(&self, user_context: &UserContext, conversion: Conversion) { + log::debug!("Sending conversion event to Event API"); // Generate a new payload - let mut payload = Payload::new(event.account_id()); + let mut payload = Payload::new(user_context.client().datafile().account_id()); + + // Add single conversion + payload.add_conversion_event(user_context.user_id(), &conversion); + + // Dispatch single conversion + payload.send() + } + + fn send_decision_event(&self, user_context: &UserContext, decision: Decision) { + log::debug!("Sending decision event to Event API"); + + // Generate a new payload + let mut payload = Payload::new(user_context.client().datafile().account_id()); // Add single decision - payload.add_event(event); - - // And send - match EventApiClient::send(payload) { - Ok(_) => { - log::info!("Succesfull request to Event API"); - } - Err(report) => { - log::error!("Failed request to Event API"); - log::error!("\n{report:?}"); - } - } + payload.add_decision_event(user_context.user_id(), &decision); + + // Dispatch single decision + payload.send() } } diff --git a/optimizely/src/event_api/trait_event_dispatcher.rs b/optimizely/src/event_api/trait_event_dispatcher.rs index 8981d57..01c9a2e 100644 --- a/optimizely/src/event_api/trait_event_dispatcher.rs +++ b/optimizely/src/event_api/trait_event_dispatcher.rs @@ -1,52 +1,14 @@ // Imports from super -use super::Event; +use crate::{client::UserContext, Conversion, Decision}; /// Trait for sending events to Optimizely Event API /// -/// It is possible to make a custom event disptacher by implementing this trait -/// ``` -/// use std::cell::RefCell; -/// use optimizely::event_api::{Event, EventDispatcher}; -/// # -/// # // Create some example IDs -/// # let account_id = "21537940595"; -/// # let user_id = "user0"; -/// # let campaign_id = "9300000133039"; -/// # let experiment_id = "9300000169122"; -/// # let variation_id = "87757"; -/// # -/// # // Create new event from above IDs -/// # let event = Event::decision(account_id, user_id, campaign_id, experiment_id, variation_id); -/// -/// // Struct that will store events instead of sending them -/// #[derive(Default)] -/// struct EventStore { -/// list: RefCell> -/// } -/// -/// // Easy way to get the length of the list inside -/// impl EventStore { -/// fn size(&self) -> usize { -/// self.list.borrow().len() -/// } -/// } -/// -/// // Implementation of the EventDispatcher trait -/// impl EventDispatcher for EventStore { -/// fn send_event(&self, event: Event) { -/// self.list.borrow_mut().push(event); -/// } -/// } -/// -/// // Initialize an empty event store -/// let event_store = EventStore::default(); -/// assert_eq!(event_store.size(), 0); -/// -/// // Send one event -/// event_store.send_event(event); -/// assert_eq!(event_store.size(), 1); -/// ``` +/// It is possible to make a custom event dispatcher by implementing this trait +/// TODO: add example again pub trait EventDispatcher { + /// Send conversion event to destination + fn send_conversion_event(&self, user_context: &UserContext, conversion: Conversion); + /// Send event to destination - fn send_event(&self, event: Event); + fn send_decision_event(&self, user_context: &UserContext, decision: Decision); } diff --git a/optimizely/src/lib.rs b/optimizely/src/lib.rs index ad4d352..12d962b 100644 --- a/optimizely/src/lib.rs +++ b/optimizely/src/lib.rs @@ -3,9 +3,12 @@ // Reimport/export of structs to make them available at top-level pub use client::Client; +pub use conversion::Conversion; +pub use decision::Decision; // Regular modules pub mod client; +pub mod conversion; pub mod datafile; pub mod decision; diff --git a/optimizely/tests/common/mod.rs b/optimizely/tests/common/mod.rs index f80dce7..6933110 100644 --- a/optimizely/tests/common/mod.rs +++ b/optimizely/tests/common/mod.rs @@ -6,8 +6,7 @@ use std::cell::RefCell; use std::rc::Rc; // Imports from Optimizely crate -use optimizely::event_api::{Event, EventDispatcher}; -use optimizely::Client; +use optimizely::{Client, client::UserContext, Conversion, Decision, event_api::EventDispatcher}; // This is the account ID of mark.biesheuvel@optimizely.com pub const ACCOUNT_ID: &str = "21537940595"; @@ -22,26 +21,37 @@ pub const FILE_PATH: &str = "../datafiles/sandbox.json"; // This is the revision number of the bundled datafile pub const REVISION: u32 = 73; -// List of Events wrapped in a reference counted mutable memory location -type EventList = Rc>>; +// List of conversions wrapped in a reference counted mutable memory location +type ConversionList = Rc>>; + +// List of decisions wrapped in a reference counted mutable memory location +type DecisionList = Rc>>; // Struct that holds the EventList and implement the EventDispatcher trait #[derive(Default)] pub(super) struct EventStore { - list: Rc>>, + conversions: ConversionList, + decisions: DecisionList, } // Return a new reference counted point to the list impl EventStore { - fn list(&self) -> Rc>> { - Rc::clone(&self.list) + fn conversions(&self) -> ConversionList { + Rc::clone(&self.conversions) + } + + fn decisions(&self) -> DecisionList { + Rc::clone(&self.decisions) } } // Implementing the EventDispatcher using the interior mutability pattern impl EventDispatcher for EventStore { - fn send_event(&self, event: Event) { - self.list.borrow_mut().push(event); + fn send_conversion_event(&self, _user_context: &UserContext, conversion: Conversion){ + self.conversions.borrow_mut().push(conversion); + } + fn send_decision_event(&self, _user_context: &UserContext, decision: Decision) { + self.decisions.borrow_mut().push(decision); } } @@ -50,14 +60,18 @@ impl EventDispatcher for EventStore { // - a list of events that was send to the EventDispatcher pub struct TestContext { pub client: Client, - pub event_list: EventList, + pub conversions: ConversionList, + pub decisions: DecisionList, } // A setup function used in multiple tests pub(super) fn setup() -> TestContext { // Create a struct to store events let event_store = EventStore::default(); - let event_list = event_store.list(); + + // Clone RC + let conversions = event_store.conversions(); + let decisions = event_store.decisions(); // Build client let client = Client::from_local_datafile(FILE_PATH) @@ -65,5 +79,5 @@ pub(super) fn setup() -> TestContext { .with_event_dispatcher(event_store) .initialize(); - TestContext { client, event_list } + TestContext { client, conversions, decisions } } diff --git a/optimizely/tests/decisions.rs b/optimizely/tests/decisions.rs index 6feb185..f6f7a7f 100644 --- a/optimizely/tests/decisions.rs +++ b/optimizely/tests/decisions.rs @@ -10,7 +10,7 @@ macro_rules! assert_decision { // Make decision for user let decision = user_context.decide($flag_key); - // Assert the decision is consitent with given values + // Assert the decision is consistent with given values assert_eq!(decision.enabled(), $enabled); assert_eq!(decision.variation_key(), $variation_key); }}; @@ -40,7 +40,7 @@ fn qa_rollout_flag() { assert_decision!(ctx, flag_key, "user15", true, "on"); // Since this key is a rollout, no events should be dispatched - assert_eq!(ctx.event_list.borrow().len(), 0); + assert_eq!(ctx.decisions.borrow().len(), 0); } #[test] @@ -83,7 +83,7 @@ fn buy_button_flag() { assert_decision!(ctx, flag_key, "user31", true, "primary"); // Each of those 32 users should dispatch an event - assert_eq!(ctx.event_list.borrow().len(), 32); + assert_eq!(ctx.decisions.borrow().len(), 32); } #[test] @@ -98,5 +98,5 @@ fn invalid_flag() { assert_decision!(ctx, flag_key, "user4", false, "off"); // Since this key does not exist, no events should be dispatched - assert_eq!(ctx.event_list.borrow().len(), 0); + assert_eq!(ctx.decisions.borrow().len(), 0); } diff --git a/optimizely/tests/user_context.rs b/optimizely/tests/user_context.rs index 2138930..2806ba1 100644 --- a/optimizely/tests/user_context.rs +++ b/optimizely/tests/user_context.rs @@ -51,5 +51,5 @@ fn user_context_track_event() { user_context.track_event("purchase"); // Assert that exactly one event is dispatched - assert_eq!(ctx.event_list.borrow().len(), 1); + assert_eq!(ctx.conversions.borrow().len(), 1); }