tauri-plugin-telemetry 0.1.3

Backend-agnostic analytics/telemetry plugin for Tauri v2 apps, with a reference Hono + Supabase backend.
Documentation
use serde_json::{Value, json};
use std::{
    sync::{Arc, Mutex as SyncMutex},
    time::Duration,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};

use crate::{
    config::Config,
    dispatcher::EventDispatcher,
    session::TrackingSession,
    sys::{self, SystemProperties},
};

/// The client used to track events.
pub struct TelemetryClient {
    is_enabled: bool,
    session: SyncMutex<TrackingSession>,
    dispatcher: Arc<EventDispatcher>,
    app_version: String,
    sdk_name: String,
    sys_info: SystemProperties,
}

impl TelemetryClient {
    /// Creates a new telemetry client.
    pub fn new(config: &Config, app_version: String) -> Self {
        let sys_info = sys::get_info();

        let is_enabled = !config.app_key.is_empty();
        let dispatcher = Arc::new(EventDispatcher::new(config, &sys_info));

        Self {
            is_enabled,
            dispatcher,
            session: SyncMutex::new(TrackingSession::new()),
            app_version,
            sdk_name: config.sdk_name.clone(),
            sys_info,
        }
    }

    /// Starts the event dispatcher loop.
    ///
    /// This must be spawned on the Tauri-managed async runtime, not via a bare
    /// `tokio::spawn`, since the calling thread (the Tauri `setup` hook) is not
    /// guaranteed to be running inside a Tokio runtime context.
    pub(crate) fn start_polling(&self, interval: Duration) {
        let dispatcher = self.dispatcher.clone();

        tauri::async_runtime::spawn(async move {
            loop {
                tokio::time::sleep(interval).await;
                dispatcher.flush().await;
            }
        });
    }

    /// Enqueues an event to be sent to the backend.
    pub fn track_event(&self, name: &str, props: Option<Value>) -> Result<(), String> {
        if !self.is_enabled {
            return Ok(());
        }

        if let Some(props) = &props
            && !matches!(props, Value::Object(_))
        {
            return Err(
                "props must be `None` or the `Object` variation of `serde_json::Value`".to_owned(),
            );
        }

        let session_id = self
            .session
            .lock()
            .expect("could not lock session")
            .eval_id();

        let ev = json!({
            "timestamp": OffsetDateTime::now_utc().format(&Rfc3339).unwrap(),
            "sessionId": session_id,
            "eventName": name,
            "systemProps": {
                "isDebug": self.sys_info.is_debug,
                "osName": self.sys_info.os_name,
                "osVersion": self.sys_info.os_version,
                "locale": self.sys_info.locale,
                "engineName": self.sys_info.engine_name,
                "engineVersion": self.sys_info.engine_version,
                "appVersion": self.app_version,
                "sdkVersion": self.sdk_name
            },
            "props": props
        });

        self.dispatcher.enqueue(ev);

        Ok(())
    }

    /// Flushes the event queue.
    pub async fn flush(&self) {
        self.dispatcher.flush().await;
    }

    /// Flushes the event queue, blocking the current thread.
    ///
    /// Uses the Tauri-managed Tokio runtime so `reqwest` has a reactor
    /// available. A bare `futures::executor::block_on` panics with "there is
    /// no reactor running" when called from a thread without a Tokio runtime
    /// context, e.g. on `RunEvent::Exit`.
    pub fn flush_blocking(&self) {
        tauri::async_runtime::block_on(async {
            self.flush().await;
        });
    }
}