alibabacloud-rum 0.1.0

Alibaba Cloud RUM SDK for native Rust applications.
Documentation
use std::collections::HashMap;

use serde_json::{json, Map, Value};

use crate::config::{RumConfig, SDK_VERSION};
use crate::context::RumContextSnapshot;
use crate::event::RumEvent;

pub(crate) fn build_payloads(config: &RumConfig, events: Vec<RumEvent>) -> Vec<Value> {
    let mut groups: HashMap<RumContextSnapshot, Vec<Value>> = HashMap::new();
    for event in events {
        groups
            .entry(event.context().clone())
            .or_default()
            .push(event.to_json());
    }

    groups
        .into_iter()
        .map(|(context, events)| build_payload(config, context, events))
        .collect()
}

fn build_payload(config: &RumConfig, context: RumContextSnapshot, events: Vec<Value>) -> Value {
    let events = events
        .into_iter()
        .map(|event| add_event_context(config, &context, event))
        .collect::<Vec<_>>();

    json!({
        "app": {
            "id": config.app_id,
            "env": config.env.as_str(),
            "version": config.app_version.as_deref().unwrap_or("-"),
            "name": config.app_name.as_deref().unwrap_or("-"),
            "type": config.app_type,
        },
        "device": {
            "id": config.utdid.as_deref().unwrap_or("-"),
            "type": config.device_type,
            "name": config.device_name.as_deref().unwrap_or("unknown"),
            "model": config.device_model.as_deref().unwrap_or("unknown"),
            "brand": config.device_brand.as_deref().unwrap_or("unknown"),
        },
        "os": {
            "type": config.os_type,
            "user_agent": format!("alibabacloud_rum/{SDK_VERSION}"),
            "version": config.os_version.as_deref().unwrap_or("unknown"),
        },
        "_v": SDK_VERSION,
        "events": events,
    })
}

fn add_event_context(config: &RumConfig, context: &RumContextSnapshot, mut event: Value) -> Value {
    let Value::Object(event_object) = &mut event else {
        return event;
    };

    let mut user = Map::new();
    user.insert(
        "id".to_string(),
        json!(config
            .user_id
            .as_deref()
            .or(config.utdid.as_deref())
            .unwrap_or("-")),
    );
    if let Some(value) = &config.user_name {
        user.insert("name".to_string(), json!(value));
    }
    if let Some(value) = &config.user_tags {
        user.insert("tags".to_string(), json!(value));
    }

    event_object.insert("user".to_string(), Value::Object(user));
    event_object.insert(
        "session".to_string(),
        json!({
            "id": context.session_id,
        }),
    );
    event_object.insert(
        "view".to_string(),
        json!({
            "id": context.view_id,
            "name": context.view_name,
        }),
    );
    event_object.insert(
        "net".to_string(),
        json!({
            "device_model": config.net_device_model,
        }),
    );
    let properties = properties(config, event_object);
    event_object.insert("properties".to_string(), properties);

    event
}

fn properties(config: &RumConfig, event_object: &Map<String, Value>) -> Value {
    let mut properties = Map::new();
    for (key, value) in &config.properties {
        properties.insert(key.clone(), json!(value));
    }
    if let Some(Value::Object(event_properties)) = event_object.get("properties") {
        for (key, value) in event_properties {
            properties.insert(key.clone(), value.clone());
        }
    }
    Value::Object(properties)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::default_platform_type;
    use crate::event::ViewEvent;

    #[test]
    fn payload_uses_context_snapshot() {
        let config = RumConfig::builder()
            .config_address("http://127.0.0.1/rum")
            .app_id("app")
            .property("channel", "test")
            .build()
            .unwrap();
        let context = RumContextSnapshot {
            session_id: "s1".to_string(),
            view_id: "v1".to_string(),
            view_name: "main-view".to_string(),
        };
        let payloads = build_payloads(&config, vec![RumEvent::View(ViewEvent::initial(context))]);
        assert_eq!(payloads.len(), 1);
        assert_eq!(payloads[0]["app"]["id"], "app");
        assert_eq!(payloads[0]["app"]["type"], default_platform_type());
        assert_eq!(payloads[0]["device"]["type"], default_platform_type());
        assert_eq!(payloads[0]["os"]["type"], default_platform_type());
        assert_eq!(payloads[0]["events"][0]["session"]["id"], "s1");
        assert_eq!(payloads[0]["events"][0]["view"]["id"], "v1");
        assert_eq!(payloads[0]["events"][0]["properties"]["channel"], "test");
    }

    #[test]
    fn payloads_are_grouped_by_context_snapshot() {
        let config = RumConfig::builder()
            .config_address("http://127.0.0.1/rum")
            .app_id("app")
            .build()
            .unwrap();
        let first_context = RumContextSnapshot {
            session_id: "s1".to_string(),
            view_id: "v1".to_string(),
            view_name: "main-view".to_string(),
        };
        let second_context = RumContextSnapshot {
            session_id: "s2".to_string(),
            view_id: "v1".to_string(),
            view_name: "main-view".to_string(),
        };

        let payloads = build_payloads(
            &config,
            vec![
                RumEvent::View(ViewEvent::initial(first_context.clone())),
                RumEvent::View(ViewEvent::initial(second_context.clone())),
                RumEvent::View(ViewEvent::initial(first_context)),
            ],
        );

        assert_eq!(payloads.len(), 2);
        for payload in payloads {
            let events = payload["events"].as_array().unwrap();
            let session_id = events[0]["session"]["id"].as_str().unwrap();
            let event_count = events.len();
            match session_id {
                "s1" => assert_eq!(event_count, 2),
                "s2" => assert_eq!(event_count, 1),
                other => panic!("unexpected session id: {other}"),
            }
        }
    }

    #[test]
    fn event_properties_are_merged_with_config_properties() {
        let config = RumConfig::builder()
            .config_address("http://127.0.0.1/rum")
            .app_id("app")
            .property("channel", "config")
            .property("shared", "config")
            .build()
            .unwrap();
        let context = RumContextSnapshot {
            session_id: "s1".to_string(),
            view_id: "v1".to_string(),
            view_name: "main-view".to_string(),
        };
        let event = json!({
            "event_type": "custom",
            "properties": {
                "sku": "123",
                "shared": "event"
            }
        });

        let event = add_event_context(&config, &context, event);

        assert_eq!(event["properties"]["channel"], "config");
        assert_eq!(event["properties"]["sku"], "123");
        assert_eq!(event["properties"]["shared"], "event");
    }

    #[test]
    fn non_object_event_properties_are_ignored() {
        let config = RumConfig::builder()
            .config_address("http://127.0.0.1/rum")
            .app_id("app")
            .property("channel", "config")
            .build()
            .unwrap();
        let context = RumContextSnapshot {
            session_id: "s1".to_string(),
            view_id: "v1".to_string(),
            view_name: "main-view".to_string(),
        };
        let event = json!({
            "event_type": "custom",
            "properties": "invalid"
        });

        let event = add_event_context(&config, &context, event);

        assert_eq!(event["properties"]["channel"], "config");
        assert_eq!(event["properties"].as_object().unwrap().len(), 1);
    }
}