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
+
+
+Woopra component for Edgee
+
+[](https://coveralls.io/github/edgee-cloud/woopra-component)
+[](https://github.com/edgee-cloud/woopra-component/issues)
+[](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