From b1e05ed1abfdaa05720dbb60ff122a801360e571 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:37:08 +0200 Subject: [PATCH 01/17] update git ignore --- .gitignore | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index cfbeb4f..c90e8eb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,16 +2,13 @@ # will have compiled files and executables debug/ target/ +.edgee/ +component.wasm -# These are backup files generated by rustfmt -**/*.rs.bk -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - -# Generated by cargo mutants -# Contains mutation testing data -**/mutants.out*/ +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can From b5d8fcd1204171c40b4943d0d4690fe4f568ff33 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:37:35 +0200 Subject: [PATCH 02/17] add initial implementation --- Cargo.toml | 17 +++ Makefile | 22 +++ edgee-component.toml | 23 ++++ src/lib.rs | 243 +++++++++++++++++++++++++++++++++ src/woopra_payload.rs | 307 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 612 insertions(+) create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 edgee-component.toml create mode 100644 src/lib.rs create mode 100644 src/woopra_payload.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fdc1720 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "woopra-component" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1.0.86" +serde = { version = "1.0.204", features = ["derive"] } +serde_qs = "0.13.0" +wit-bindgen = "0.41.0" + +[dev-dependencies] +pretty_assertions = "1.4.1" +uuid = { version = "1.10.0", features = ["v4"] } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7a898b3 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: all +MAKEFLAGS += --silent + +all: help + +help: + @grep -E '^[a-zA-Z1-9\._-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | sort \ + | sed -e "s/^Makefile://" -e "s///" \ + | awk 'BEGIN { FS = ":.*?## " }; { printf "\033[36m%-30s\033[0m %s\n", $$1, $$2 }' + +build: ## Build the wasi component + edgee components build + +test: ## Test the component on host platform + cargo test --lib + +test.coverage: + cargo llvm-cov --all-features + +test.coverage.html: + cargo llvm-cov --all-features --open diff --git a/edgee-component.toml b/edgee-component.toml new file mode 100644 index 0000000..11c8817 --- /dev/null +++ b/edgee-component.toml @@ -0,0 +1,23 @@ +manifest-version = 1 + +[component] +name = "woopra-component" +version = "1.0.0" + +category = "data-collection" +subcategory = "analytics" +description = "Example Rust component for data collection" +documentation = "https://github.com/edgee-cloud/example-rust-component" +repository = "https://github.com/edgee-cloud/example-rust-component" +language = "Rust" +wit-version = "1.0.0" + +[component.build] +command = "cargo build --target wasm32-wasip2 --release --target-dir ./target && mv ./target/wasm32-wasip2/release/woopra_component.wasm ./woopra.wasm" +output_path = "woopra.wasm" + +[component.settings.project_name] +title = "Project Name" +type = "string" +description = "Your Woopra Project Name (e.g. 'mywebsite.com')" +required = true diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4e93308 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,243 @@ +use crate::exports::edgee::components::data_collection::{ + Data, Dict, EdgeeRequest, Event, HttpMethod, +}; +use anyhow::Context; +use exports::edgee::components::data_collection::Guest; +use std::collections::HashMap; +use woopra_payload::{WoopraPayloadIdentify, WoopraPayloadTrack}; + +mod woopra_payload; + +wit_bindgen::generate!({world: "data-collection", path: ".edgee/wit", generate_all}); +export!(Component); + +struct Component; + +const WOOPRA_HOST: &str = "https://www.woopra.com"; +const WOOPRA_TRACK_ENDPOINT: &str = "/track/ce"; +const WOOPRA_IDENTIFY_ENDPOINT: &str = "/track/identify"; + +impl Guest for Component { + fn page(edgee_event: Event, settings_dict: Dict) -> Result { + if let Data::Page(ref data) = edgee_event.data { + let settings = Settings::new(settings_dict).map_err(|e| e.to_string())?; + + let mut payload = + WoopraPayloadTrack::new(&edgee_event, settings.project_name, "pv".to_string()) + .map_err(|e| e.to_string())?; + + payload.add_page_properties(data); + + let querystring = serde_qs::to_string(&payload).map_err(|e| e.to_string())?; + + Ok( + build_edgee_request(querystring, WOOPRA_TRACK_ENDPOINT.to_string()) + .map_err(|e| e.to_string())?, + ) + } else { + Err("Missing page data".to_string()) + } + } + + fn track(edgee_event: Event, settings_dict: Dict) -> Result { + if let Data::Track(ref data) = edgee_event.data { + if data.name.is_empty() { + return Err("Track is not set".to_string()); + } + + let settings = Settings::new(settings_dict).map_err(|e| e.to_string())?; + + let mut payload = + WoopraPayloadTrack::new(&edgee_event, settings.project_name, data.name.clone()) + .map_err(|e| e.to_string())?; + + payload.add_track_properties(data); + + let querystring = serde_qs::to_string(&payload).map_err(|e| e.to_string())?; + + Ok( + build_edgee_request(querystring, WOOPRA_TRACK_ENDPOINT.to_string()) + .map_err(|e| e.to_string())?, + ) + } else { + Err("Missing track data".to_string()) + } + } + + fn user(edgee_event: Event, settings_dict: Dict) -> Result { + if let Data::User(ref data) = edgee_event.data { + let settings = Settings::new(settings_dict).map_err(|e| e.to_string())?; + + let mut payload = WoopraPayloadIdentify::new(&edgee_event, settings.project_name) + .map_err(|e| e.to_string())?; + + payload.add_user_properties(data); + + let querystring = serde_qs::to_string(&payload).map_err(|e| e.to_string())?; + + Ok( + build_edgee_request(querystring, WOOPRA_IDENTIFY_ENDPOINT.to_string()) + .map_err(|e| e.to_string())?, + ) + } else { + Err("Missing track data".to_string()) + } + } +} + +fn build_edgee_request(querystring: String, endpoint: String) -> anyhow::Result { + let headers = vec![(String::from("content-length"), String::from("0"))]; + + Ok(EdgeeRequest { + method: HttpMethod::Get, + url: format!("{}{}?{}", WOOPRA_HOST, endpoint, querystring), + headers, + forward_client_headers: true, + body: String::new(), + }) +} + +pub struct Settings { + pub project_name: String, +} + +impl Settings { + pub fn new(settings_dict: Dict) -> anyhow::Result { + let settings_map: HashMap = settings_dict + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + + let project_name = settings_map + .get("project_name") + .context("Missing example setting")? + .to_string(); + + Ok(Self { project_name }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::exports::edgee::components::data_collection::{ + Campaign, Client, Context, Data, EventType, PageData, Session, UserData, + }; + use exports::edgee::components::data_collection::Consent; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + fn sample_user_data(edgee_id: String) -> UserData { + UserData { + user_id: "123".to_string(), + anonymous_id: "456".to_string(), + edgee_id, + properties: vec![ + ("prop1".to_string(), "value1".to_string()), + ("prop2".to_string(), "10".to_string()), + ], + } + } + + fn sample_context(edgee_id: String, locale: String, session_start: bool) -> Context { + Context { + page: sample_page_data(), + user: sample_user_data(edgee_id), + client: Client { + city: "Paris".to_string(), + ip: "192.168.0.1".to_string(), + locale, + timezone: "CET".to_string(), + user_agent: "Chrome".to_string(), + user_agent_architecture: "x86".to_string(), + user_agent_bitness: "64".to_string(), + user_agent_full_version_list: "abc".to_string(), + user_agent_version_list: "abc".to_string(), + user_agent_mobile: "mobile".to_string(), + user_agent_model: "don't know".to_string(), + os_name: "MacOS".to_string(), + os_version: "latest".to_string(), + screen_width: 1024, + screen_height: 768, + screen_density: 2.0, + continent: "Europe".to_string(), + country_code: "FR".to_string(), + country_name: "France".to_string(), + region: "West Europe".to_string(), + }, + campaign: Campaign { + name: "random".to_string(), + source: "random".to_string(), + medium: "random".to_string(), + term: "random".to_string(), + content: "random".to_string(), + creative_format: "random".to_string(), + marketing_tactic: "random".to_string(), + }, + session: Session { + session_id: "random".to_string(), + previous_session_id: "random".to_string(), + session_count: 2, + session_start, + first_seen: 123, + last_seen: 123, + }, + } + } + + fn sample_page_data() -> PageData { + PageData { + name: "page name".to_string(), + category: "category".to_string(), + keywords: vec!["value1".to_string(), "value2".into()], + title: "page title".to_string(), + url: "https://example.com/full-url?test=1".to_string(), + path: "/full-path".to_string(), + search: "?test=1".to_string(), + referrer: "https://example.com/another-page".to_string(), + properties: vec![ + ("prop1".to_string(), "value1".to_string()), + ("prop2".to_string(), "10".to_string()), + ("currency".to_string(), "USD".to_string()), + ], + } + } + + fn sample_page_event( + consent: Option, + edgee_id: String, + locale: String, + session_start: bool, + ) -> Event { + Event { + uuid: Uuid::new_v4().to_string(), + timestamp: 123, + timestamp_millis: 123, + timestamp_micros: 123, + event_type: EventType::Page, + data: Data::Page(sample_page_data()), + context: sample_context(edgee_id, locale, session_start), + consent, + } + } + + #[test] + fn page_works_fine() { + let event = sample_page_event( + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("your-credentials".to_string(), "abc".to_string())]; + let result = Component::page(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Post); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!(edgee_request.url.starts_with("https://example.com/"), true); + // add more checks (headers, querystring, etc.) + } +} diff --git a/src/woopra_payload.rs b/src/woopra_payload.rs new file mode 100644 index 0000000..09443b2 --- /dev/null +++ b/src/woopra_payload.rs @@ -0,0 +1,307 @@ +use serde::Serialize; +use std::collections::HashMap; + +use crate::exports::edgee::components::data_collection::Event; + +#[derive(Serialize, Debug, Default)] +pub(crate) struct WoopraPayloadTrack { + project: String, + event: String, + timestamp: String, + + #[serde( + skip_serializing_if = "HashMap::is_empty", + serialize_with = "serialize_cv_prefixed", + flatten + )] + visitor_properties: HashMap, + #[serde( + skip_serializing_if = "HashMap::is_empty", + serialize_with = "serialize_ce_prefixed", + flatten + )] + event_properties: HashMap, + #[serde( + skip_serializing_if = "HashMap::is_empty", + serialize_with = "serialize_cs_prefixed", + flatten + )] + session_properties: HashMap, + + #[serde(skip_serializing_if = "Option::is_none")] + screen: Option, // e.g. "1920x1080" + #[serde(skip_serializing_if = "Option::is_none")] + language: Option, // e.g. "en-US" + #[serde(skip_serializing_if = "Option::is_none")] + referer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + timeout: Option, // won't use for now (default is 30000) + + #[serde(skip_serializing_if = "Option::is_none")] + browser: Option, + #[serde(skip_serializing_if = "Option::is_none")] + os: Option, + #[serde(skip_serializing_if = "Option::is_none")] + device: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + app: Option, +} +impl WoopraPayloadTrack { + pub(crate) fn new(edgee_event: &Event, project: String, event: String) -> anyhow::Result { + let mut payload = WoopraPayloadTrack { + event, + project, + app: Some("Edgee".to_string()), + timestamp: edgee_event.timestamp.to_string(), + ..WoopraPayloadTrack::default() + }; + + // context.page stuff + payload.add_page_properties(&edgee_event.context.page); + + if !edgee_event.context.client.locale.is_empty() { + payload.language = Some(edgee_event.context.client.locale.clone()); + } + + if !edgee_event + .context + .client + .user_agent_full_version_list + .is_empty() + { + payload.browser = Some( + edgee_event + .context + .client + .user_agent_full_version_list + .clone(), + ); + } + + if !edgee_event.context.client.os_name.is_empty() { + if !edgee_event.context.client.os_version.is_empty() { + payload.os = Some(format!( + "{} {}", + edgee_event.context.client.os_name.clone(), + edgee_event.context.client.os_version.clone(), + )); + } else { + payload.os = Some(edgee_event.context.client.os_name.clone()); + } + } + + if edgee_event.context.client.screen_width.is_positive() + && edgee_event.context.client.screen_height.is_positive() + { + payload.screen = Some(format!( + "{:?}x{:?}", + edgee_event.context.client.screen_width.clone(), + edgee_event.context.client.screen_height.clone() + )); + } + + // user + if !edgee_event.context.user.anonymous_id.is_empty() { + payload.visitor_properties.insert( + "anonymous_id".to_string(), + edgee_event.context.user.anonymous_id.clone(), + ); + } + if !edgee_event.context.user.user_id.is_empty() { + payload.visitor_properties.insert( + "user_id".to_string(), + edgee_event.context.user.user_id.clone(), + ); + } + + // user properties + if !edgee_event.context.user.properties.is_empty() { + for (key, value) in edgee_event.context.user.properties.clone().iter() { + payload + .visitor_properties + .insert(key.to_string(), value.clone()); + } + } + + // geo ip + if !edgee_event.context.client.country_code.is_empty() { + payload.visitor_properties.insert( + "country".to_string(), + edgee_event.context.client.country_code.clone(), + ); + } + + // ip address + if !edgee_event.context.client.ip.is_empty() { + payload.ip = Some(edgee_event.context.client.ip.clone()); + } + + // session + payload.session_properties.insert( + "session_id".to_string(), + edgee_event.context.session.session_id.clone(), + ); + payload.session_properties.insert( + "session_count".to_string(), + edgee_event + .context + .session + .session_count + .clone() + .to_string(), + ); + + Ok(payload) + } + + // this method can be used to add page properties to the payload (from event.data or context.page) + pub(crate) fn add_page_properties( + &mut self, + page: &crate::exports::edgee::components::data_collection::PageData, + ) { + if !page.title.is_empty() { + self.event_properties + .insert("title".to_string(), page.title.clone()); + } + if !page.url.is_empty() { + let uri = format!("{}{}", page.url.clone(), page.search.clone()); + self.event_properties.insert("uri".to_string(), uri); + } + if !page.referrer.is_empty() { + self.referer = Some(page.referrer.clone()); + } + for (key, value) in page.properties.iter() { + let key = key.replace(" ", "_"); + self.event_properties + .insert(format!("page_{}", key), value.to_string()); + } + } + + // this method can be used to add user properties to the payload (from event.data or context.user) + pub(crate) fn add_track_properties( + &mut self, + data: &crate::exports::edgee::components::data_collection::TrackData, + ) { + // track data properties + if !data.properties.is_empty() { + for (key, value) in data.properties.clone().iter() { + self.event_properties + .insert(key.to_string(), value.to_string()); + } + } + } +} + +#[derive(Serialize, Debug, Default)] +pub(crate) struct WoopraPayloadIdentify { + project: String, + + #[serde( + skip_serializing_if = "HashMap::is_empty", + serialize_with = "serialize_cv_prefixed", + flatten + )] + visitor_properties: HashMap, + + #[serde(skip_serializing_if = "Option::is_none")] + cv_id: Option, // default identifier (could be cv_email too) + + #[serde(skip_serializing_if = "Option::is_none")] + cookie: Option, // optional cookie id (required only if no other identifier is provided) +} +impl WoopraPayloadIdentify { + pub(crate) fn new(edgee_event: &Event, project: String) -> anyhow::Result { + let mut payload = WoopraPayloadIdentify { + project, + ..WoopraPayloadIdentify::default() + }; + + // context.user stuff + payload.add_user_properties(&edgee_event.context.user); + + // geo ip + if !edgee_event.context.client.country_code.is_empty() { + payload.visitor_properties.insert( + "country".to_string(), + edgee_event.context.client.country_code.clone(), + ); + } + + Ok(payload) + } + + // this method can be used to add user properties to the payload (from event.data or context.user) + pub(crate) fn add_user_properties( + &mut self, + user: &crate::exports::edgee::components::data_collection::UserData, + ) { + if !user.anonymous_id.is_empty() { + self.cv_id = Some(user.anonymous_id.clone()); + } + if !user.user_id.is_empty() { + // overwrite the anonymous_id with user_id if available + self.cv_id = Some(user.user_id.clone()); + } + + // user properties + if !user.properties.is_empty() { + for (key, value) in user.properties.clone().iter() { + self.visitor_properties + .insert(key.to_string(), value.clone()); + } + } + } +} + +// Helper function to serialize HashMap with "cv_" prefix +fn serialize_cv_prefixed(map: &HashMap, serializer: S) -> Result +where + S: serde::Serializer, +{ + let mut prefixed_map: HashMap = HashMap::new(); + for (key, value) in map.iter() { + let prefixed_key = if key.starts_with("cv_") { + key.clone() + } else { + format!("cv_{}", key) + }; + prefixed_map.insert(prefixed_key, value.clone()); + } + prefixed_map.serialize(serializer) +} + +// Helper function to serialize HashMap with "ce_" prefix +fn serialize_ce_prefixed(map: &HashMap, serializer: S) -> Result +where + S: serde::Serializer, +{ + let mut prefixed_map: HashMap = HashMap::new(); + for (key, value) in map.iter() { + let prefixed_key = if key.starts_with("cv_") { + key.clone() + } else { + format!("ce_{}", key) + }; + prefixed_map.insert(prefixed_key, value.clone()); + } + prefixed_map.serialize(serializer) +} + +// Helper function to serialize HashMap with "ce_" prefix +fn serialize_cs_prefixed(map: &HashMap, serializer: S) -> Result +where + S: serde::Serializer, +{ + let mut prefixed_map: HashMap = HashMap::new(); + for (key, value) in map.iter() { + let prefixed_key = if key.starts_with("cv_") { + key.clone() + } else { + format!("cs_{}", key) + }; + prefixed_map.insert(prefixed_key, value.clone()); + } + prefixed_map.serialize(serializer) +} From f275cb4dd075ce0414b02699fcd45c7e1ac6f00a Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:39:15 +0200 Subject: [PATCH 03/17] add GitHub Action workflows --- .github/CODEOWNERS | 1 + .github/workflows/check.yml | 96 ++++++++++++++++++++++++ .github/workflows/wasm-build-release.yml | 33 ++++++++ 3 files changed, 130 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/wasm-build-release.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..541175b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @edgee-cloud/edgeers diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..934d91e --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,96 @@ +name: Check +on: + push: + branches: + - main + pull_request: + +env: + EDGEE_API_TOKEN: ${{ secrets.EDGEE_API_TOKEN }} + +jobs: + check: + name: cargo check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + target: wasm32-wasip2 # WebAssembly target + components: rustfmt + - uses: edgee-cloud/install-edgee-cli@v0.2.0 + - run: edgee component wit + - run: cargo check + + fmt: + name: cargo fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + target: wasm32-wasip2 # WebAssembly target + - uses: edgee-cloud/install-edgee-cli@v0.2.0 + - run: edgee component wit + - uses: actions-rust-lang/rustfmt@v1 + + clippy: + name: clippy + runs-on: ubuntu-latest + permissions: + checks: write + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + target: wasm32-wasip2 # WebAssembly target + - uses: edgee-cloud/install-edgee-cli@v0.2.0 + - run: edgee component wit + - uses: wearerequired/lint-action@master + with: + clippy: true + + build: + name: cargo build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + target: wasm32-wasip2 # WebAssembly target + - uses: edgee-cloud/install-edgee-cli@v0.2.0 + - run: edgee component build + - name: Verify .wasm file exists + run: | + if [ ! -f "./clickhouse.wasm" ]; then + echo "❌ Error: clickhouse.wasm not found" >&2 + exit 1 + fi + + test: + name: cargo test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + target: wasm32-wasip2 # WebAssembly target + - uses: edgee-cloud/install-edgee-cli@v0.2.0 + - run: edgee component wit + - run: make test + + coverage: + name: coverage & coveralls + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + target: wasm32-wasip2 # WebAssembly target + - uses: taiki-e/install-action@cargo-llvm-cov + - uses: edgee-cloud/install-edgee-cli@v0.2.0 + - run: edgee component wit + - run: make test.coverage.lcov + - uses: coverallsapp/github-action@v2 diff --git a/.github/workflows/wasm-build-release.yml b/.github/workflows/wasm-build-release.yml new file mode 100644 index 0000000..ba49e3a --- /dev/null +++ b/.github/workflows/wasm-build-release.yml @@ -0,0 +1,33 @@ +name: Build and Release WASM + +permissions: + contents: write +on: + release: + types: [ published ] + +env: + EDGEE_API_TOKEN: ${{ secrets.EDGEE_API_TOKEN }} + +jobs: + check: + name: Build and release wasm component + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + target: wasm32-wasip2 + - uses: edgee-cloud/install-edgee-cli@v0.2.0 + - run: edgee component build + - name: Upload WASM to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./woopra.wasm + asset_name: woopra.wasm + asset_content_type: application/wasm + - name: Push to Edgee Component Registry + run: edgee component push edgee --yes --changelog "${{ github.event.release.body }}" From 08b33c73661368c7e069593038c7254130bfdd99 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:51:20 +0200 Subject: [PATCH 04/17] add Contributing file --- CONTRIBUTING.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..842fea7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Edgee Contributor Guidelines + +Welcome! This project is created by the team at [Edgee](https://www.edgee.cloud). +We're glad you're interested in contributing! We welcome contributions from people of all backgrounds +who are interested in making great software with us. + +At Edgee, we aspire to empower everyone to create interactive experiences. To do this, +we're exploring and pushing the boundaries of new technologies, and sharing our learnings with the open source community. + +If you have ideas for collaboration, email us at opensource@edgee.cloud or join our [Slack](https://www.edgee.cloud/slack)! + +We're also hiring full-time engineers to work with us everywhere! Check out our current job postings [here](https://github.com/edgee-cloud/careers/issues). + +## Issues + +### Feature Requests + +If you have ideas or how to improve our projects, you can suggest features by opening a GitHub issue. +Make sure to include details about the feature or change, and describe any uses cases it would enable. + +Feature requests will be tagged as `enhancement` and their status will be updated in the comments of the issue. + +### Bugs + +When reporting a bug or unexpected behavior in a project, make sure your issue describes steps +to reproduce the behavior, including the platform you were using, what steps you took, and any error messages. + +Reproducible bugs will be tagged as `bug` and their status will be updated in the comments of the issue. + +### Wontfix + +Issues will be closed and tagged as `wontfix` if we decide that we do not wish to implement it, +usually due to being misaligned with the project vision or out of scope. We will comment on the issue with more detailed reasoning. + +## Contribution Workflow + +### Open Issues + +If you're ready to contribute, start by looking at our open issues tagged as [`help wanted`](../../issues?q=is%3Aopen+is%3Aissue+label%3A"help+wanted") or [`good first issue`](../../issues?q=is%3Aopen+is%3Aissue+label%3A"good+first+issue"). + +You can comment on the issue to let others know you're interested in working on it or to ask questions. + +### Making Changes + +1. Fork the repository. + +2. Review the [Development Workflow](#development-workflow) section to understand how to run the project locally. + +3. Create a new feature [branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-and-deleting-branches-within-your-repository). + +4. Make your changes on your branch. Ensure that there are no build errors by running the project with your changes locally. + +5. [Submit the branch as a Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) pointing to the `main` branch of the Edgee repository. A maintainer should comment and/or review your Pull Request within a few days. Although depending on the circumstances, it may take longer. + +### Development Workflow + +#### Setup and run Edgee + +```bash +cargo run +``` + +#### Test + +```bash +cargo test +``` + +This command will be triggered to each PR as a requirement for merging it. + + +## Licensing + +Unless otherwise specified, all Edgee open source projects shall comply with the Apache 2.0 licence. Please see the [LICENSE](LICENSE) file for more information. + +## Contributor Terms + +Thank you for your interest in Edgee’ open source project. By providing a contribution (new or modified code, +other input, feedback or suggestions etc.) you agree to these Contributor Terms. + +You confirm that each of your contributions has been created by you and that you are the copyright owner. +You also confirm that you have the right to provide the contribution to us and that you do it under the +Apache 2.0 licence. + +If you want to contribute something that is not your original creation, you may submit it to Edgee separately +from any contribution, including details of its source and of any license or other restriction +(such as related patents, trademarks, agreements etc.) + +Please also note that our projects are released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md) to +ensure that they are welcoming places for everyone to contribute. By participating in any Edgee open source project, +you agree to keep to the Contributor Code of Conduct. From 2b68723b23f26e6c37ad362c14441c1c43c03e73 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:51:31 +0200 Subject: [PATCH 05/17] update documentation --- README.md | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8568776..0c7c285 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,92 @@ -# woopra-component -Woopra Edgee Component + + +

Woopra component for Edgee

+ +[![Coverage Status](https://coveralls.io/repos/github/edgee-cloud/woopra-component/badge.svg)](https://coveralls.io/github/edgee-cloud/woopra-component) +[![GitHub issues](https://img.shields.io/github/issues/edgee-cloud/woopra-component.svg)](https://github.com/edgee-cloud/woopra-component/issues) +[![Edgee Component Registry](https://img.shields.io/badge/Edgee_Component_Registry-Public-green.svg)](https://www.edgee.cloud/edgee/woopra) + + +This component enables seamless integration between [Edgee](https://www.edgee.cloud) and [Woopra](https://www.woopra.com/), allowing you to collect and forward analytics events to your Woopra project. + + +## Quick Start + +1. Download the latest component version from our [releases page](../../releases) +2. Place the `woopra.wasm` file in your server (e.g., `/var/edgee/components`) +3. Add the following configuration to your `edgee.toml`: + +```toml +[[destinations.data_collection]] +id = "woopra" +file = "/var/edgee/components/woopra.wasm" +settings.project = "example.com" +``` + + +## Event Handling + +### Event Mapping +The component maps Edgee events to Woopra events as follows. + +| Edgee Event | Woopra event | Description | +|-------------|--------------------------------|--------------------------------------| +| Page | Track request with name = "pv" | Triggered when a user views a page | +| Track | Track request | Triggered for custom events | +| User | Identify request | Use it to update visitor properties | + + +## Configuration Options + +### Basic Configuration +```toml +[[destinations.data_collection]] +id = "woopra" +file = "/var/edgee/components/woopra.wasm" +settings.project = "example.com" +``` + +### Event Controls +Control which events are forwarded to Woopra: +```toml +settings.edgee_page_event_enabled = true # Enable/disable page view tracking +settings.edgee_track_event_enabled = true # Enable/disable custom event tracking +settings.edgee_user_event_enabled = true # Enable/disable user identification +``` + + +## Development + +### Building from Source +Prerequisites: +- [Rust](https://www.rust-lang.org/tools/install) + +Build command: +```bash +edgee component build +``` + +Test command: +```bash +make test +``` + +Test coverage command: +```bash +make test.coverage[.html] +``` + +### Contributing +Interested in contributing? Read our [contribution guidelines](./CONTRIBUTING.md) + +### Security +Report security vulnerabilities to [security@edgee.cloud](mailto:security@edgee.cloud) From e0f6c2e3c9d7c736004c1f8f607ff10dccc29df3 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:55:18 +0200 Subject: [PATCH 06/17] update test --- src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4e93308..3f95aed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -230,14 +230,14 @@ mod tests { "fr".to_string(), true, ); - let settings = vec![("your-credentials".to_string(), "abc".to_string())]; + let settings = vec![("project_name".to_string(), "example.com".to_string())]; let result = Component::page(event, settings); assert_eq!(result.is_err(), false); let edgee_request = result.unwrap(); - assert_eq!(edgee_request.method, HttpMethod::Post); + assert_eq!(edgee_request.method, HttpMethod::Get); assert_eq!(edgee_request.body.is_empty(), true); - assert_eq!(edgee_request.url.starts_with("https://example.com/"), true); + assert_eq!(edgee_request.url.starts_with("https://www.woopra.com"), true); // add more checks (headers, querystring, etc.) } } From 769cabcaafcb7a68eb6d4f3eaaf11b1222439303 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:57:55 +0200 Subject: [PATCH 07/17] fix workflow typo --- .github/workflows/check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 934d91e..e16b449 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -64,8 +64,8 @@ jobs: - run: edgee component build - name: Verify .wasm file exists run: | - if [ ! -f "./clickhouse.wasm" ]; then - echo "❌ Error: clickhouse.wasm not found" >&2 + if [ ! -f "./woopra.wasm" ]; then + echo "❌ Error: woopra.wasm not found" >&2 exit 1 fi From 819ff6088faa4419eb410262da03ae7a4884fea6 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:58:28 +0200 Subject: [PATCH 08/17] add icon, update manifest description and links --- edgee-component.toml | 11 ++++++++--- woopra-icon.png | Bin 0 -> 2137 bytes 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 woopra-icon.png diff --git a/edgee-component.toml b/edgee-component.toml index 11c8817..95d72af 100644 --- a/edgee-component.toml +++ b/edgee-component.toml @@ -6,11 +6,16 @@ version = "1.0.0" category = "data-collection" subcategory = "analytics" -description = "Example Rust component for data collection" -documentation = "https://github.com/edgee-cloud/example-rust-component" -repository = "https://github.com/edgee-cloud/example-rust-component" +documentation = "https://github.com/edgee-cloud/woopra-component" +repository = "https://github.com/edgee-cloud/woopra-component" language = "Rust" wit-version = "1.0.0" +icon-path = "woopra-icon.png" +description = ''' +This component enables seamless integration between [Edgee](https://www.edgee.cloud) +and [Woopra](https://www.woopra.com/), +allowing you to collect and forward analytics events to your Woopra project. +''' [component.build] command = "cargo build --target wasm32-wasip2 --release --target-dir ./target && mv ./target/wasm32-wasip2/release/woopra_component.wasm ./woopra.wasm" diff --git a/woopra-icon.png b/woopra-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a1d4333e08143aa4d7be0ae3dc4ba81f87cfc633 GIT binary patch literal 2137 zcmV-f2&VUmP)C0002tP)t-s|NsAa zG3IqJ<$f~ff-~rfHR^^m>5ew*V=LipF5_t};&LzKWGvx{HR@g};A}19{rms&>izZa z{_W@d`S$+z^Zvo3^y1h0&aC#pk@S;5?9;dRi9zY--1~Dp<$pHkt8?&{TSnmT2qS z!1;zx>4sP6x`6ZD%K4*H?}$a|p>*xQiS*5)_LNTRd{5_>W$TJu>7jD&%e(cdf$yx1 z?`S#Wl5gsKQRTs{^V=sc7XSbUGf6~2RCt{2T4{IMNDxE^rv#YOoDyeZjByOMu^o1o zkc5!<|No$!nVuQ-=)m9wzx|+ko4k#YRi)~lo}LzoM2r|QV#J6MBSwrEF=E8{pQGDu zPNp}rR=P90=`|adA4*=jowt&Jd@`R;QM4wvrH^4R-%itMLY)>8xB`M{Yk4OVjsU6 z1YrRAuXDQc9OXaoLm2X@ycz?;=)Amf-*R`f7+ti`iTbB;;~W!$>1a zVe9{S{D1GSvA20s34x($ee@fTuH@$)NCSuh^2hOo zEMUMtd+F&T&Qu2ouH}by-NFKjMQH(z05f5yhU|_N8IS+|Eq=0VuLBC|{G)-c>xu(X z0Hgqu@D1ldxs_K8m>;som2PknG6j&f7ky!X41re&m<3cp;WxThBcI0>d_An;EBE~Z zI^b)iV_^j}0%{UusDQ%IyVV>72m>POS^%}aq3~y~FuQU3PgL;sP`m7I>M}VJ2WBDr)6AmxB!C4#+%grMQB0@`wxo zJgN?8`mzSfba24ITlI?r20#uJ3OPubZYqT=XdT&Reo^^#{RjNK&iFTK&8>_xkO9nq zLg6R-X*-1!Bzwp4ClWm+2MBmQqPx-7fAM7&3IUe@pEs$1lu&SVt6#lQ4A6BKvbegm z2EnU`ECFrqzz!ZE1r~sU<4>L4j;09E$8eACFy!K@W7{YOEM=$A;(&nG(flkC6$RF2 z&&9uB83KQS^(Hq$04)d8cL+!{6$6NZtLSS-5CN`$nKTo9E|oBOkARDfs=(5JMqXPL zWr*Q3{gI0W^xh*NQCcY>uq-F~ytKpXaH|NQ65b~u@q!GnsDg21GVgE>Faa~t;Bo%c z`$9nQ1*ZVT_lSa4#SwrI+?bvPsdptI#l*`1z;*a_BN_q04@o#BeT5Y8o@K~T9?KAL zgp?tIUp97tEyIb46&zoPDzK%DT-5<98@3oP#u?k=heFh30)&L4OL1-a7yM7d$Da*T zP{(z!uw#kRhW3^V=p8FyBHQ6D7QkAetv?gHJ0%A}dRH7Vmn47gav*|>_`Pv!f_4BD=Pqe)t6SjhP;?{a7`RJ>|7xEZGJYGn;ueT?->4( z3l(r8{^=J`EI!DVY@5p{by&7!4O77pO=(5$#5~`A`WkXJ8u;`1l{Td|>&9>nih5mQ z(y-g28}rVCU;tn%*fBjCf-V-f#o_8}u7(M;yzM0XPL6|5`713AA zf2mm7T^3yPjO%ZzuhgcLGxrz}>TbT@E)6 zpGi2I{p0#X;#$)TEkcDB0?r>;Qht;#Uk-XQA$+^Bu z(TFfXSDt@-uGjYjr9qekY`*TE^9u|<3A*lQ*U$YG4vKQU4-3v~xdb*J@Ff<8N`2$% zKDZC{5BeGpN4Gtg^p`7F>x`E_293)PdiEGGV#J6MBSwrEF=E7s@qzdkNOifW9K-@v P00000NkvXXu0mjf`0EVx literal 0 HcmV?d00001 From 3974943179feaf405e564f42621b7a05d7259b7a Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 17:59:54 +0200 Subject: [PATCH 09/17] cargo fmt --- src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 3f95aed..06b04ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -237,7 +237,10 @@ mod tests { let edgee_request = result.unwrap(); assert_eq!(edgee_request.method, HttpMethod::Get); assert_eq!(edgee_request.body.is_empty(), true); - assert_eq!(edgee_request.url.starts_with("https://www.woopra.com"), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); // add more checks (headers, querystring, etc.) } } From 4b17d8f5506a24499391b87879d5a7c81ab45b13 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 18:00:06 +0200 Subject: [PATCH 10/17] add lcov command --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 7a898b3..d90ae00 100644 --- a/Makefile +++ b/Makefile @@ -18,5 +18,8 @@ test: ## Test the component on host platform test.coverage: cargo llvm-cov --all-features +test.coverage.lcov: + cargo llvm-cov --all-features --lcov --output-path lcov.info + test.coverage.html: cargo llvm-cov --all-features --open From ed58ae57006946f1d5efa3704e1a6b96af0d689d Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 18:01:35 +0200 Subject: [PATCH 11/17] fix typo in error message Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 06b04ba..1aea008 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,7 +80,7 @@ impl Guest for Component { .map_err(|e| e.to_string())?, ) } else { - Err("Missing track data".to_string()) + Err("Missing user data".to_string()) } } } From 9c98f00d76662f66422acffdae43a7a69f0bacf0 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 18:03:20 +0200 Subject: [PATCH 12/17] fix typo in prefix utility (ce_) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/woopra_payload.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/woopra_payload.rs b/src/woopra_payload.rs index 09443b2..9215560 100644 --- a/src/woopra_payload.rs +++ b/src/woopra_payload.rs @@ -279,7 +279,7 @@ where { let mut prefixed_map: HashMap = HashMap::new(); for (key, value) in map.iter() { - let prefixed_key = if key.starts_with("cv_") { + let prefixed_key = if key.starts_with("ce_") { key.clone() } else { format!("ce_{}", key) From 41f96a6b12ac2fdc15634a26f7c4b46a4d568a18 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 18:03:30 +0200 Subject: [PATCH 13/17] fix typo in prefix utility (cs_) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/woopra_payload.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/woopra_payload.rs b/src/woopra_payload.rs index 9215560..f4c29e3 100644 --- a/src/woopra_payload.rs +++ b/src/woopra_payload.rs @@ -296,7 +296,7 @@ where { let mut prefixed_map: HashMap = HashMap::new(); for (key, value) in map.iter() { - let prefixed_key = if key.starts_with("cv_") { + let prefixed_key = if key.starts_with("cs_") { key.clone() } else { format!("cs_{}", key) From cb910b08cb45251af6be7ad5344d2ebee9fc1ecb Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Mon, 23 Jun 2025 18:20:06 +0200 Subject: [PATCH 14/17] add comments --- src/woopra_payload.rs | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/woopra_payload.rs b/src/woopra_payload.rs index f4c29e3..fbbf9ad 100644 --- a/src/woopra_payload.rs +++ b/src/woopra_payload.rs @@ -3,12 +3,17 @@ use std::collections::HashMap; use crate::exports::edgee::components::data_collection::Event; +// documentation: https://docs.woopra.com/reference/track-ce +// this struct is only used with Page and Track events #[derive(Serialize, Debug, Default)] pub(crate) struct WoopraPayloadTrack { + // only these 3 fields are required project: String, event: String, timestamp: String, + // all properties are prefixed with "cv_" (visitor), "ce_" (event), "cs_" (session) + // and need to be serialized as flattened maps #[serde( skip_serializing_if = "HashMap::is_empty", serialize_with = "serialize_cv_prefixed", @@ -28,6 +33,7 @@ pub(crate) struct WoopraPayloadTrack { )] session_properties: HashMap, + // all the other fields are optional #[serde(skip_serializing_if = "Option::is_none")] screen: Option, // e.g. "1920x1080" #[serde(skip_serializing_if = "Option::is_none")] @@ -48,23 +54,26 @@ pub(crate) struct WoopraPayloadTrack { #[serde(skip_serializing_if = "Option::is_none")] app: Option, } + impl WoopraPayloadTrack { pub(crate) fn new(edgee_event: &Event, project: String, event: String) -> anyhow::Result { let mut payload = WoopraPayloadTrack { event, project, - app: Some("Edgee".to_string()), + app: Some("Edgee".to_string()), // custom app value (like a special SDK) timestamp: edgee_event.timestamp.to_string(), ..WoopraPayloadTrack::default() }; - // context.page stuff + // add properties from context.page payload.add_page_properties(&edgee_event.context.page); + // language/locale if !edgee_event.context.client.locale.is_empty() { payload.language = Some(edgee_event.context.client.locale.clone()); } + // user agent if !edgee_event .context .client @@ -80,6 +89,7 @@ impl WoopraPayloadTrack { ); } + // OS name and version if !edgee_event.context.client.os_name.is_empty() { if !edgee_event.context.client.os_version.is_empty() { payload.os = Some(format!( @@ -92,6 +102,7 @@ impl WoopraPayloadTrack { } } + // screen size if edgee_event.context.client.screen_width.is_positive() && edgee_event.context.client.screen_height.is_positive() { @@ -102,7 +113,7 @@ impl WoopraPayloadTrack { )); } - // user + // user ids if !edgee_event.context.user.anonymous_id.is_empty() { payload.visitor_properties.insert( "anonymous_id".to_string(), @@ -125,20 +136,18 @@ impl WoopraPayloadTrack { } } - // geo ip + // country code & IP address if !edgee_event.context.client.country_code.is_empty() { payload.visitor_properties.insert( "country".to_string(), edgee_event.context.client.country_code.clone(), ); } - - // ip address if !edgee_event.context.client.ip.is_empty() { payload.ip = Some(edgee_event.context.client.ip.clone()); } - // session + // session id and count payload.session_properties.insert( "session_id".to_string(), edgee_event.context.session.session_id.clone(), @@ -179,7 +188,7 @@ impl WoopraPayloadTrack { } } - // this method can be used to add user properties to the payload (from event.data or context.user) + // this method can be used to add track properties to the payload (from event.data) pub(crate) fn add_track_properties( &mut self, data: &crate::exports::edgee::components::data_collection::TrackData, @@ -194,10 +203,15 @@ impl WoopraPayloadTrack { } } +// documentation: https://docs.woopra.com/reference/track-identify +// this struct is only used with User events #[derive(Serialize, Debug, Default)] pub(crate) struct WoopraPayloadIdentify { + // this is the only required field project: String, + // visitor properties are prefixed with "cv_" (visitor) + // and need to be serialized as flattened maps #[serde( skip_serializing_if = "HashMap::is_empty", serialize_with = "serialize_cv_prefixed", @@ -205,12 +219,15 @@ pub(crate) struct WoopraPayloadIdentify { )] visitor_properties: HashMap, + // default identifier (could be cv_email too) #[serde(skip_serializing_if = "Option::is_none")] - cv_id: Option, // default identifier (could be cv_email too) + cv_id: Option, + // optional cookie id (required only if no other identifier is provided) #[serde(skip_serializing_if = "Option::is_none")] - cookie: Option, // optional cookie id (required only if no other identifier is provided) + cookie: Option, } + impl WoopraPayloadIdentify { pub(crate) fn new(edgee_event: &Event, project: String) -> anyhow::Result { let mut payload = WoopraPayloadIdentify { @@ -218,7 +235,7 @@ impl WoopraPayloadIdentify { ..WoopraPayloadIdentify::default() }; - // context.user stuff + // add properties from context.user payload.add_user_properties(&edgee_event.context.user); // geo ip From 364f8f19a52b63d4cd4b3e6616021f13bd771146 Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Tue, 24 Jun 2025 11:06:52 +0200 Subject: [PATCH 15/17] add more tests for coverage --- src/lib.rs | 340 +++++++++++++++++++++++++++++++++++++++++- src/woopra_payload.rs | 8 +- 2 files changed, 338 insertions(+), 10 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 1aea008..e3eabaa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -122,7 +122,7 @@ mod tests { use super::*; use crate::exports::edgee::components::data_collection::{ - Campaign, Client, Context, Data, EventType, PageData, Session, UserData, + Campaign, Client, Context, Data, EventType, PageData, Session, TrackData, UserData, }; use exports::edgee::components::data_collection::Consent; use pretty_assertions::assert_eq; @@ -140,6 +140,27 @@ mod tests { } } + fn sample_user_data_without_properties(edgee_id: String) -> UserData { + UserData { + user_id: "123".to_string(), + anonymous_id: "456".to_string(), + edgee_id, + properties: vec![], + } + } + + fn sample_user_data_with_cv_properties(edgee_id: String) -> UserData { + UserData { + user_id: "123".to_string(), + anonymous_id: "456".to_string(), + edgee_id, + properties: vec![ + ("prop_name".to_string(), "value1".to_string()), // will be prefixed with cv_ + ("cv_prop_ok".to_string(), "10".to_string()), // won't be prefixed with cv_ + ], + } + } + fn sample_context(edgee_id: String, locale: String, session_start: bool) -> Context { Context { page: sample_page_data(), @@ -150,12 +171,12 @@ mod tests { locale, timezone: "CET".to_string(), user_agent: "Chrome".to_string(), - user_agent_architecture: "x86".to_string(), + user_agent_architecture: "unknown".to_string(), user_agent_bitness: "64".to_string(), user_agent_full_version_list: "abc".to_string(), user_agent_version_list: "abc".to_string(), user_agent_mobile: "mobile".to_string(), - user_agent_model: "don't know".to_string(), + user_agent_model: "unknown".to_string(), os_name: "MacOS".to_string(), os_version: "latest".to_string(), screen_width: 1024, @@ -204,6 +225,37 @@ mod tests { } } + fn sample_track_data(event_name: String) -> TrackData { + return TrackData { + name: event_name, + products: vec![], + properties: vec![ + ("prop1".to_string(), "value1".to_string()), + ("prop2".to_string(), "10".to_string()), + ("currency".to_string(), "USD".to_string()), + ], + }; + } + + fn sample_track_data_without_properties(event_name: String) -> TrackData { + return TrackData { + name: event_name, + products: vec![], + properties: vec![], + }; + } + + fn sample_track_data_with_already_prefixed_properties(event_name: String) -> TrackData { + return TrackData { + name: event_name, + products: vec![], + properties: vec![ + ("prop_name".to_string(), "value1".to_string()), + ("ce_prop_ok".to_string(), "10".to_string()), + ], + }; + } + fn sample_page_event( consent: Option, edgee_id: String, @@ -222,6 +274,83 @@ mod tests { } } + fn sample_track_event( + event_name: String, + consent: Option, + edgee_id: String, + locale: String, + session_start: bool, + ) -> Event { + return Event { + uuid: Uuid::new_v4().to_string(), + timestamp: 123, + timestamp_millis: 123, + timestamp_micros: 123, + event_type: EventType::Track, + data: Data::Track(sample_track_data(event_name)), + context: sample_context(edgee_id, locale, session_start), + consent: consent, + }; + } + + fn sample_track_event_without_properties( + event_name: String, + consent: Option, + edgee_id: String, + locale: String, + session_start: bool, + ) -> Event { + return Event { + uuid: Uuid::new_v4().to_string(), + timestamp: 123, + timestamp_millis: 123, + timestamp_micros: 123, + event_type: EventType::Track, + data: Data::Track(sample_track_data_without_properties(event_name)), + context: sample_context(edgee_id, locale, session_start), + consent: consent, + }; + } + + fn sample_track_event_with_already_prefixed_properties( + event_name: String, + consent: Option, + edgee_id: String, + locale: String, + session_start: bool, + ) -> Event { + return Event { + uuid: Uuid::new_v4().to_string(), + timestamp: 123, + timestamp_millis: 123, + timestamp_micros: 123, + event_type: EventType::Track, + data: Data::Track(sample_track_data_with_already_prefixed_properties( + event_name, + )), + context: sample_context(edgee_id, locale, session_start), + consent: consent, + }; + } + + fn sample_user_event( + consent: Option, + edgee_id: String, + locale: String, + session_start: bool, + ) -> Event { + return Event { + uuid: Uuid::new_v4().to_string(), + timestamp: 123, + timestamp_millis: 123, + timestamp_micros: 123, + event_type: EventType::User, + data: Data::User(sample_user_data_without_properties(edgee_id.clone())), + context: sample_context(edgee_id, locale, session_start), + consent: consent, + }; + } + #[test] fn page_works_fine() { let event = sample_page_event( @@ -241,6 +370,209 @@ mod tests { edgee_request.url.starts_with("https://www.woopra.com"), true ); - // add more checks (headers, querystring, etc.) + assert_eq!(edgee_request.url.contains("?"), true); // querystring + assert_eq!(edgee_request.url.contains("project="), true); // query param + } + + #[test] + fn track_works_fine() { + let event = sample_track_event( + "test_event".to_string(), + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("project_name".to_string(), "example.com".to_string())]; + let result = Component::track(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Get); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); + assert_eq!(edgee_request.url.contains("?"), true); // querystring + assert_eq!(edgee_request.url.contains("project="), true); // query param + } + + #[test] + fn user_works_fine() { + let event = sample_user_event( + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("project_name".to_string(), "example.com".to_string())]; + let result = Component::user(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Get); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); + assert_eq!(edgee_request.url.contains("?"), true); // querystring + assert_eq!(edgee_request.url.contains("project="), true); // query param + } + + #[test] + fn track_event_without_client_os_version() { + let mut event = sample_track_event( + "test_event".to_string(), + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("project_name".to_string(), "example.com".to_string())]; + + // remove the client OS version to simulate a missing property + event.context.client.os_version = String::new(); + + let result = Component::track(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Get); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); + } + + #[test] + fn track_event_without_client_os_name() { + let mut event = sample_track_event( + "test_event".to_string(), + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("project_name".to_string(), "example.com".to_string())]; + + // remove the client OS version to simulate a missing property + event.context.client.os_name = String::new(); + + let result = Component::track(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Get); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); + } + + #[test] + fn track_event_without_context_user_properties() { + let mut event = sample_track_event( + "test_event".to_string(), + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("project_name".to_string(), "example.com".to_string())]; + + // remove user properties + event.context.user.properties = vec![]; + + let result = Component::track(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Get); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); + } + + #[test] + fn track_event_with_empty_properties() { + let event = sample_track_event_without_properties( + "test_event".to_string(), + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("project_name".to_string(), "example.com".to_string())]; + + let result = Component::track(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Get); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); + } + + #[test] + fn track_event_with_already_prefixed_properties() { + let event = sample_track_event_with_already_prefixed_properties( + "test_event".to_string(), + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("project_name".to_string(), "example.com".to_string())]; + + let result = Component::track(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Get); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); + assert_eq!(edgee_request.url.contains("ce_prop_name="), true); // query param + assert_eq!(edgee_request.url.contains("ce_prop_ok="), true); // query param + assert_eq!(edgee_request.url.contains("ce_ce_prop_ok="), false); // query param + } + + #[test] + fn user_event_with_already_prefixed_properties() { + let mut event = sample_user_event( + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + let settings = vec![("project_name".to_string(), "example.com".to_string())]; + + event.data = Data::User(sample_user_data_with_cv_properties("abc".to_string())); + + let result = Component::user(event, settings); + + assert_eq!(result.is_err(), false); + let edgee_request = result.unwrap(); + assert_eq!(edgee_request.method, HttpMethod::Get); + assert_eq!(edgee_request.body.is_empty(), true); + assert_eq!( + edgee_request.url.starts_with("https://www.woopra.com"), + true + ); + assert_eq!(edgee_request.url.contains("?"), true); // querystring + assert_eq!(edgee_request.url.contains("cv_prop_name="), true); // query param + assert_eq!(edgee_request.url.contains("cv_prop_ok="), true); // query param + assert_eq!(edgee_request.url.contains("cv_cv_prop_ok="), false); // query param } } diff --git a/src/woopra_payload.rs b/src/woopra_payload.rs index fbbf9ad..ed18b01 100644 --- a/src/woopra_payload.rs +++ b/src/woopra_payload.rs @@ -306,18 +306,14 @@ where prefixed_map.serialize(serializer) } -// Helper function to serialize HashMap with "ce_" prefix +// Helper function to serialize HashMap with "cs_" prefix fn serialize_cs_prefixed(map: &HashMap, serializer: S) -> Result where S: serde::Serializer, { let mut prefixed_map: HashMap = HashMap::new(); for (key, value) in map.iter() { - let prefixed_key = if key.starts_with("cs_") { - key.clone() - } else { - format!("cs_{}", key) - }; + let prefixed_key = format!("cs_{}", key); prefixed_map.insert(prefixed_key, value.clone()); } prefixed_map.serialize(serializer) From 2cbc2b5205b29a251d5cc5fb65fcdac719e502ed Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Tue, 24 Jun 2025 11:13:00 +0200 Subject: [PATCH 16/17] update doc URL in manifest --- edgee-component.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/edgee-component.toml b/edgee-component.toml index 95d72af..a10dc7f 100644 --- a/edgee-component.toml +++ b/edgee-component.toml @@ -6,7 +6,7 @@ version = "1.0.0" category = "data-collection" subcategory = "analytics" -documentation = "https://github.com/edgee-cloud/woopra-component" +documentation = "https://www.edgee.cloud/docs/components/data-collection/woopra" repository = "https://github.com/edgee-cloud/woopra-component" language = "Rust" wit-version = "1.0.0" From 2172a803b51cce1be787b47e0550a7d2384baeaf Mon Sep 17 00:00:00 2001 From: Alex Casalboni Date: Tue, 24 Jun 2025 11:13:13 +0200 Subject: [PATCH 17/17] bump crate to 1.0.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index fdc1720..74bbc5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "woopra-component" -version = "0.1.0" +version = "1.0.0" edition = "2021" [lib]