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..e16b449 --- /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 "./woopra.wasm" ]; then + echo "❌ Error: woopra.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 }}" 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 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. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..74bbc5e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "woopra-component" +version = "1.0.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..d90ae00 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.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.lcov: + cargo llvm-cov --all-features --lcov --output-path lcov.info + +test.coverage.html: + cargo llvm-cov --all-features --open 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 +
+

+ + + + Edgee + + +

+
+ +

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) diff --git a/edgee-component.toml b/edgee-component.toml new file mode 100644 index 0000000..a10dc7f --- /dev/null +++ b/edgee-component.toml @@ -0,0 +1,28 @@ +manifest-version = 1 + +[component] +name = "woopra-component" +version = "1.0.0" + +category = "data-collection" +subcategory = "analytics" +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" +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" +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..e3eabaa --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,578 @@ +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 user 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, TrackData, 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_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(), + 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: "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: "unknown".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_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, + 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, + } + } + + 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( + Some(Consent::Granted), + "abc".to_string(), + "fr".to_string(), + true, + ); + 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::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_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 new file mode 100644 index 0000000..ed18b01 --- /dev/null +++ b/src/woopra_payload.rs @@ -0,0 +1,320 @@ +use serde::Serialize; +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", + 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, + + // 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")] + 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()), // custom app value (like a special SDK) + timestamp: edgee_event.timestamp.to_string(), + ..WoopraPayloadTrack::default() + }; + + // 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 + .user_agent_full_version_list + .is_empty() + { + payload.browser = Some( + edgee_event + .context + .client + .user_agent_full_version_list + .clone(), + ); + } + + // 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!( + "{} {}", + 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()); + } + } + + // screen size + 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 ids + 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()); + } + } + + // 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(), + ); + } + if !edgee_event.context.client.ip.is_empty() { + payload.ip = Some(edgee_event.context.client.ip.clone()); + } + + // session id and count + 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 track properties to the payload (from event.data) + 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()); + } + } + } +} + +// 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", + flatten + )] + visitor_properties: HashMap, + + // default identifier (could be cv_email too) + #[serde(skip_serializing_if = "Option::is_none")] + cv_id: Option, + + // optional cookie id (required only if no other identifier is provided) + #[serde(skip_serializing_if = "Option::is_none")] + cookie: Option, +} + +impl WoopraPayloadIdentify { + pub(crate) fn new(edgee_event: &Event, project: String) -> anyhow::Result { + let mut payload = WoopraPayloadIdentify { + project, + ..WoopraPayloadIdentify::default() + }; + + // add properties from context.user + 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("ce_") { + key.clone() + } else { + format!("ce_{}", key) + }; + prefixed_map.insert(prefixed_key, value.clone()); + } + prefixed_map.serialize(serializer) +} + +// 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 = format!("cs_{}", key); + prefixed_map.insert(prefixed_key, value.clone()); + } + prefixed_map.serialize(serializer) +} diff --git a/woopra-icon.png b/woopra-icon.png new file mode 100644 index 0000000..a1d4333 Binary files /dev/null and b/woopra-icon.png differ