telemetrydeck_wasm/
core.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use uuid::Uuid;
6
7const VERSION: &str = env!("CARGO_PKG_VERSION");
8const CLIENT_VERSION_KEY: &str = "telemetryClientVersion";
9
10/// An instance of an outgoing telemetry signal
11///
12///This struct represents a single telemetry event that will be sent to TelemetryDeck.
13/// Signals are automatically created by [`TelemetryDeck::send`] and [`TelemetryDeck::send_sync`]
14/// methods - you typically don't need to construct these manually.
15///
16/// # Serialization
17///
18/// This struct serializes to JSON in camelCase format as expected by the TelemetryDeck API:
19///
20/// ```json
21/// {
22///   "receivedAt": "2025-01-15T10:30:00Z",
23///   "appID": "xxx-xxx-xxx",
24///   "clientUser": "hashed-user-id",
25///   "sessionID": "session-uuid",
26///   "type": "signalType",
27///   "payload": ["key1:value1", "key2:value2"],
28///   "isTestMode": "false",
29///   "floatValue": 42.5
30/// }
31/// ```
32///
33/// # Privacy
34///
35/// - `client_user` is always SHA-256 hashed before being set
36/// - `session_id` is a UUID v4 generated per client instance
37/// - `is_test_mode` is serialized as a string ("true" or "false")
38/// - `float_value` is omitted from JSON when `None`
39#[derive(Serialize, Deserialize, Debug)]
40#[serde(rename_all = "camelCase")]
41pub struct Signal {
42    /// Timestamp when this signal was generated (UTC)
43    pub received_at: DateTime<Utc>,
44
45    /// The TelemetryDeck App ID this signal belongs to
46    #[serde(rename = "appID")]
47    pub app_id: String,
48
49    /// SHA-256 hashed user identifier
50    ///
51    /// This value is automatically hashed by the client before transmission.
52    /// The server will hash it again with its own salt for privacy.
53    pub client_user: String,
54
55    /// Session identifier (UUID v4)
56    ///
57    /// Persists for the lifetime of a [`TelemetryDeck`] instance.
58    /// Can be reset using [`TelemetryDeck::reset_session`].
59    #[serde(rename = "sessionID")]
60    pub session_id: String,
61
62    /// The type/name of this signal (e.g., "userLogin", "buttonClick")
63    #[serde(rename = "type")]
64    pub signal_type: String,
65
66    /// Custom parameters encoded as "key:value" strings
67    ///
68    /// Created from the HashMap passed to `send()` or `send_sync()`.
69    /// Keys containing colons are sanitized (`:` → `_`).
70    pub payload: Vec<String>,
71
72    /// Whether this is a test signal (serialized as string "true" or "false")
73    ///
74    /// Test signals are shown separately in the TelemetryDeck UI.
75    pub is_test_mode: String,
76
77    /// Optional floating-point value associated with this signal
78    ///
79    /// Useful for tracking numeric metrics like revenue, duration, score, etc.
80    /// Omitted from JSON when `None`.
81    #[serde(rename = "floatValue")]
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub float_value: Option<f64>,
84}
85
86/// TelemetryDeck API client
87///
88/// This is the main entry point for sending telemetry signals to TelemetryDeck.
89/// The client handles session management, user identifier hashing, and HTTP communication.
90///
91/// # Examples
92///
93/// ## Basic Usage
94///
95/// ```no_run
96/// use telemetrydeck_wasm::TelemetryDeck;
97///
98/// let client = TelemetryDeck::new("YOUR-APP-ID");
99/// client.send("userLogin", Some("user@example.com"), None, None, None);
100/// ```
101///
102/// ## With Configuration
103///
104/// ```no_run
105/// use telemetrydeck_wasm::TelemetryDeck;
106/// use std::collections::HashMap;
107///
108/// let client = TelemetryDeck::new_with_config(
109///     "YOUR-APP-ID",
110///     Some("my-tenant".to_string()),  // namespace
111///     Some("random-salt-64-chars".to_string()),  // salt
112///     HashMap::new(),  // default params
113/// );
114/// ```
115///
116/// # Platform Support
117///
118/// - **Native**: Uses `reqwest` + `tokio::spawn`
119/// - **WASM**: Uses `reqwasm` + `spawn_local`
120///
121/// # Privacy
122///
123/// - User identifiers are always SHA-256 hashed
124/// - Optional salt is concatenated after user ID before hashing
125/// - Session IDs are random UUIDs
126#[derive(Debug)]
127pub struct TelemetryDeck {
128    /// Base URL of the TelemetryDeck service
129    ///
130    /// Default: `https://nom.telemetrydeck.com`
131    url: String,
132
133    /// Your TelemetryDeck App ID
134    pub app_id: String,
135
136    /// Optional namespace for multi-tenant deployments
137    ///
138    /// When set, signals are sent to `/v2/namespace/{namespace}/`
139    /// instead of `/v2/`.
140    pub namespace: Option<String>,
141
142    /// Optional salt for user identifier hashing
143    ///
144    /// The salt is concatenated after the user identifier before
145    /// SHA-256 hashing: `hash(user_id + salt)`.
146    ///
147    /// # Security Note
148    ///
149    /// It is recommended to use a cryptographically random salt of at least
150    /// 64 characters. The salt should be unique per application but consistent
151    /// across all users of the same application.
152    pub salt: Option<String>,
153
154    /// Default parameters appended to all outgoing signals
155    ///
156    /// These are merged with per-signal parameters.
157    /// The library version is automatically added as `telemetryClientVersion`.
158    pub default_params: HashMap<String, String>,
159
160    /// Current session identifier (UUID v4)
161    ///
162    /// Generated automatically when the client is created.
163    /// Can be reset using [`TelemetryDeck::reset_session`].
164    pub session_id: String,
165}
166
167impl TelemetryDeck {
168    /// Create a new instance with the specified application id
169    #[must_use]
170    pub fn new(app_id: &str) -> Self {
171        Self::new_with_config(app_id, None, None, HashMap::new())
172    }
173
174    /// Create a new instance with the specified application id and configuration
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use telemetrydeck_wasm::TelemetryDeck;
180    /// use std::collections::HashMap;
181    ///
182    /// // Create client with namespace for multi-tenant deployment
183    /// let client = TelemetryDeck::new_with_config(
184    ///     "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
185    ///     Some("my-namespace".to_string()),
186    ///     None,
187    ///     HashMap::new(),
188    /// );
189    ///
190    /// // Create client with salt for enhanced user hashing
191    /// let client = TelemetryDeck::new_with_config(
192    ///     "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
193    ///     None,
194    ///     Some("your-64-char-random-salt-here".to_string()),
195    ///     HashMap::new(),
196    /// );
197    ///
198    /// // Create client with default parameters
199    /// let mut defaults = HashMap::new();
200    /// defaults.insert("environment".to_string(), "production".to_string());
201    /// let client = TelemetryDeck::new_with_config(
202    ///     "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
203    ///     None,
204    ///     None,
205    ///     defaults,
206    /// );
207    /// ```
208    #[must_use]
209    pub fn new_with_config(
210        app_id: &str,
211        namespace: Option<String>,
212        salt: Option<String>,
213        params: HashMap<String, String>,
214    ) -> Self {
215        TelemetryDeck {
216            url: String::from("https://nom.telemetrydeck.com"),
217            app_id: app_id.to_string(),
218            namespace,
219            salt,
220            default_params: Self::adding_params(
221                &params,
222                Some(HashMap::from([(
223                    CLIENT_VERSION_KEY.to_string(),
224                    VERSION.to_string(),
225                )])),
226            ),
227            session_id: Uuid::new_v4().to_string(),
228        }
229    }
230
231    /// Reset the session id for future signals
232    pub fn reset_session(&mut self, new_session_id: Option<String>) {
233        self.session_id = new_session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
234    }
235
236    /// Create a signal with the specified parameters
237    pub(crate) fn create_signal(
238        &self,
239        signal_type: &str,
240        client_user: Option<&str>,
241        payload: Option<HashMap<String, String>>,
242        is_test_mode: Option<bool>,
243        float_value: Option<f64>,
244    ) -> Signal {
245        let params = Self::adding_params(&self.default_params, payload);
246        let payload = Self::encoded_payload(params);
247
248        let client_user = client_user.map_or_else(
249            || "rust".to_string(),
250            |u| {
251                let user_with_salt = if let Some(salt) = &self.salt {
252                    format!("{}{}", u, salt)
253                } else {
254                    u.to_string()
255                };
256                let mut sha256 = Sha256::new();
257                sha256.update(user_with_salt.as_bytes());
258                format!("{:x}", sha256.finalize())
259            },
260        );
261        Signal {
262            received_at: Utc::now(),
263            app_id: self.app_id.clone(),
264            client_user,
265            session_id: self.session_id.clone(),
266            signal_type: signal_type.to_string(),
267            payload,
268            is_test_mode: is_test_mode.unwrap_or(false).to_string(),
269            float_value,
270        }
271    }
272
273    /// Build the API URL for sending signals
274    pub(crate) fn build_url(&self) -> String {
275        if let Some(namespace) = &self.namespace {
276            format!("{}/v2/namespace/{}/", self.url, namespace)
277        } else {
278            format!("{}/v2/", self.url)
279        }
280    }
281
282    fn adding_params(
283        params1: &HashMap<String, String>,
284        params2: Option<HashMap<String, String>>,
285    ) -> HashMap<String, String> {
286        let mut result = params1.clone();
287        if let Some(params) = params2 {
288            result.extend(params);
289        }
290        result
291    }
292
293    /// Encode parameters as "key:value" strings
294    ///
295    /// Colons in parameter keys are replaced with underscores to avoid
296    /// conflicts with the "key:value" encoding format.
297    fn encoded_payload(params: HashMap<String, String>) -> Vec<String> {
298        params
299            .into_iter()
300            .map(|(k, v)| format!("{}:{}", k.replace(':', "_"), v))
301            .collect()
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::TelemetryDeck;
308    use std::collections::HashMap;
309    const VERSION: &str = env!("CARGO_PKG_VERSION");
310
311    #[test]
312    fn create_signal_without_user() {
313        let sut = TelemetryDeck::new("1234");
314        let result = sut.create_signal("signal_type", None, None, None, None);
315        assert_eq!(result.client_user, "rust".to_string());
316        assert_eq!(result.signal_type, "signal_type".to_string());
317        assert_eq!(result.app_id, "1234".to_string());
318        assert_eq!(result.is_test_mode, "false".to_string());
319        assert_eq!(
320            result.payload,
321            vec![format!("telemetryClientVersion:{VERSION}")]
322        );
323        assert_eq!(result.float_value, None);
324    }
325
326    #[test]
327    fn create_signal_with_user_is_hashed() {
328        let sut = TelemetryDeck::new("1234");
329        let result = sut.create_signal("signal_type", Some("clientUser"), None, None, None);
330        assert_eq!(
331            result.client_user,
332            "6721870580401922549fe8fdb09a064dba5b8792fa018d3bd9ffa90fe37a0149".to_string()
333        );
334        assert_eq!(result.signal_type, "signal_type".to_string());
335        assert_eq!(result.app_id, "1234".to_string());
336        assert_eq!(result.is_test_mode, "false".to_string());
337        assert_eq!(
338            result.payload,
339            vec![format!("telemetryClientVersion:{VERSION}")]
340        );
341    }
342
343    #[test]
344    fn create_signal_with_user_and_salt_is_hashed() {
345        let sut = TelemetryDeck::new_with_config(
346            "1234",
347            None,
348            Some("someSalt".to_string()),
349            HashMap::new(),
350        );
351        let result = sut.create_signal("signal_type", Some("clientUser"), None, None, None);
352        assert_eq!(
353            result.client_user,
354            "ffdd613ce521b2e94b8931bdadffd96857f6abbde6c0ee1fcf0b76127fbb9e5a".to_string()
355        );
356    }
357
358    #[test]
359    fn create_signal_with_float_value() {
360        let sut = TelemetryDeck::new("1234");
361        let result = sut.create_signal("signal_type", None, None, None, Some(42.5));
362        assert_eq!(result.float_value, Some(42.5));
363
364        // Verify serialization includes floatValue
365        let json = serde_json::to_string(&result).unwrap();
366        assert!(json.contains("\"floatValue\":42.5"));
367    }
368
369    #[test]
370    fn create_signal_without_float_value_omits_field() {
371        let sut = TelemetryDeck::new("1234");
372        let result = sut.create_signal("signal_type", None, None, None, None);
373        assert_eq!(result.float_value, None);
374
375        // Verify serialization omits floatValue when None
376        let json = serde_json::to_string(&result).unwrap();
377        assert!(!json.contains("floatValue"));
378    }
379
380    #[test]
381    fn build_url_without_namespace() {
382        let sut = TelemetryDeck::new("1234");
383        assert_eq!(sut.build_url(), "https://nom.telemetrydeck.com/v2/");
384    }
385
386    #[test]
387    fn build_url_with_namespace() {
388        let sut = TelemetryDeck::new_with_config(
389            "1234",
390            Some("my-namespace".to_string()),
391            None,
392            HashMap::new(),
393        );
394        assert_eq!(
395            sut.build_url(),
396            "https://nom.telemetrydeck.com/v2/namespace/my-namespace/"
397        );
398    }
399
400    #[test]
401    fn reset_session() {
402        let mut sut = TelemetryDeck::new("1234");
403        let session1 = sut.session_id.clone();
404        sut.reset_session(None);
405        let session2 = sut.session_id.clone();
406        assert_ne!(session1, session2);
407    }
408
409    #[test]
410    fn reset_session_with_specific_id() {
411        let mut sut = TelemetryDeck::new("1234");
412        sut.reset_session(Some("my session".to_string()));
413        let session2 = sut.session_id.clone();
414        assert_eq!(session2, "my session".to_string());
415    }
416}