tauri-plugin-telemetry 0.1.3

Backend-agnostic analytics/telemetry plugin for Tauri v2 apps, with a reference Hono + Supabase backend.
Documentation
use std::time::Duration;

use log::debug;
use reqwest::Url;

use crate::InitOptions;

/// Resolved configuration used by the client and dispatcher.
#[derive(Debug, Clone)]
pub struct Config {
    pub app_key: String,
    pub ingest_api_url: Url,
    pub flush_interval: Duration,
    /// Name of the HTTP header carrying the App Key.
    pub app_key_header: String,
    /// Value reported as `systemProps.sdkVersion` on each event.
    pub sdk_name: String,
}

/// Default ingest path appended to the base host.
pub(crate) const DEFAULT_API_PATH: &str = "/v1/events";
/// Default name of the header carrying the App Key.
pub(crate) const DEFAULT_APP_KEY_HEADER: &str = "App-Key";

/// Placeholder URL used by [`Config::default`] (disabled config). Never used
/// for real requests because `app_key` is empty, which disables tracking.
static DISABLED_URL: &str = "http://localhost";

#[cfg(not(debug_assertions))]
static DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(60);

#[cfg(debug_assertions)]
static DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(2);

impl Config {
    pub fn new(app_key: String, opts: InitOptions) -> Self {
        // The backend host must be provided explicitly via `InitOptions.host`.
        // There is no implicit region/host resolution — the plugin is backend
        // agnostic.
        let Some(base_url) = opts.host.clone() else {
            debug!(
                "No backend host configured. Set `InitOptions.host` to point \
                 the plugin at your analytics backend. Tracking will be \
                 disabled. (App Key was '{}')",
                app_key
            );
            return Config::default();
        };

        let api_path = opts
            .api_path
            .unwrap_or_else(|| DEFAULT_API_PATH.to_string());
        let ingest_api_url = match join_url(&base_url, &api_path) {
            Some(url) => url,
            None => {
                debug!(
                    "Failed to build ingest URL from host '{}' and path '{}'. \
                     Tracking will be disabled.",
                    base_url, api_path
                );
                return Config::default();
            }
        };

        let app_key_header = opts
            .app_key_header
            .unwrap_or_else(|| DEFAULT_APP_KEY_HEADER.to_string());
        let sdk_name = opts.sdk_name.unwrap_or_else(|| {
            concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION")).to_string()
        });

        Self {
            app_key,
            ingest_api_url,
            flush_interval: opts.flush_interval.unwrap_or(DEFAULT_FLUSH_INTERVAL),
            app_key_header,
            sdk_name,
        }
    }
}

/// Joins a base host URL with an API path, tolerating whether each side has a
/// leading/trailing slash. Returns `None` if the resulting URL is invalid.
fn join_url(base: &str, path: &str) -> Option<Url> {
    let trimmed_base = base.trim_end_matches('/');
    let normalized_path = if path.starts_with('/') {
        path.to_string()
    } else {
        format!("/{}", path)
    };
    Url::parse(&format!("{}{}", trimmed_base, normalized_path)).ok()
}

impl Default for Config {
    fn default() -> Self {
        Self {
            app_key: String::new(),
            ingest_api_url: Url::parse(DISABLED_URL).unwrap(),
            flush_interval: DEFAULT_FLUSH_INTERVAL,
            app_key_header: DEFAULT_APP_KEY_HEADER.to_string(),
            sdk_name: concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION")).to_string(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn opts(host: Option<&str>) -> InitOptions {
        InitOptions {
            host: host.map(String::from),
            ..Default::default()
        }
    }

    #[test]
    fn no_host_disables_regardless_of_key() {
        // Region-style keys no longer resolve a host; host is required.
        let cfg = Config::new("A-US-abc".into(), opts(None));
        assert!(cfg.app_key.is_empty());

        let cfg = Config::new("A-EU-abc".into(), opts(None));
        assert!(cfg.app_key.is_empty());

        let cfg = Config::new("A-SH-abc".into(), opts(None));
        assert!(cfg.app_key.is_empty());

        let cfg = Config::new("just-a-key".into(), opts(None));
        assert!(cfg.app_key.is_empty());
    }

    #[test]
    fn host_with_arbitrary_key_works() {
        let cfg = Config::new(
            "my-app-key-123".into(),
            opts(Some("https://analytics.myapp.com")),
        );
        assert_eq!(cfg.app_key, "my-app-key-123");
        assert_eq!(
            cfg.ingest_api_url.as_str(),
            "https://analytics.myapp.com/v1/events"
        );
    }

    #[test]
    fn host_with_region_style_key_works() {
        // A region-style key is just an opaque string now; host still wins.
        let cfg = Config::new("A-EU-abc".into(), opts(Some("https://analytics.myapp.com")));
        assert_eq!(
            cfg.ingest_api_url.as_str(),
            "https://analytics.myapp.com/v1/events"
        );
    }

    #[test]
    fn default_api_path_is_v1_events() {
        let cfg = Config::new("k".into(), opts(Some("https://analytics.myapp.com")));
        assert!(cfg.ingest_api_url.as_str().ends_with("/v1/events"));
    }

    #[test]
    fn custom_api_path_is_used() {
        let mut o = opts(Some("https://analytics.myapp.com"));
        o.api_path = Some("/ingest".into());
        let cfg = Config::new("k".into(), o);
        assert_eq!(
            cfg.ingest_api_url.as_str(),
            "https://analytics.myapp.com/ingest"
        );
    }

    #[test]
    fn custom_host_with_trailing_slash() {
        let cfg = Config::new("k".into(), opts(Some("https://analytics.myapp.com/")));
        assert_eq!(
            cfg.ingest_api_url.as_str(),
            "https://analytics.myapp.com/v1/events"
        );
    }

    #[test]
    fn invalid_host_disables() {
        let cfg = Config::new("k".into(), opts(Some("not a url")));
        assert!(cfg.app_key.is_empty());
    }

    #[test]
    fn custom_app_key_header() {
        let mut o = opts(Some("https://x.test"));
        o.app_key_header = Some("X-App-Key".into());
        let cfg = Config::new("k".into(), o);
        assert_eq!(cfg.app_key_header, "X-App-Key");
    }

    #[test]
    fn default_app_key_header_is_app_key() {
        let cfg = Config::new("k".into(), opts(Some("https://x.test")));
        assert_eq!(cfg.app_key_header, "App-Key");
    }

    #[test]
    fn custom_sdk_name() {
        let mut o = opts(Some("https://x.test"));
        o.sdk_name = Some("myapp@1.0.0".into());
        let cfg = Config::new("k".into(), o);
        assert_eq!(cfg.sdk_name, "myapp@1.0.0");
    }
}